Compare commits
102 Commits
a53b0fcc4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d0b7524f | |||
| 8ed87a23e9 | |||
| c9a7dee7ff | |||
| 794ca61358 | |||
| 11aff71415 | |||
| e1dc4595a2 | |||
| fa763bd968 | |||
| 9bce381497 | |||
| 227d8b515f | |||
| c40b4b919c | |||
| 434acb0470 | |||
| 1a70a60dba | |||
| 5cacb62d4c | |||
| d5648e0b2c | |||
| c291f331fc | |||
| 1faeff4ee9 | |||
| eb096d915d | |||
| abc436514a | |||
| b67dde0427 | |||
| 4abec850a3 | |||
| 6c54a8ae95 | |||
| b421945b16 | |||
| 6eb312451b | |||
| 9c00e167f6 | |||
| 5ca5df72b6 | |||
| 06f7fc930a | |||
| 1e6589a39f | |||
| 7bc85b2528 | |||
| 9bab06c1e7 | |||
| fb5cc801ee | |||
| d87bb00208 | |||
| 0684f15859 | |||
| c2a6b99fbd | |||
| c05a1b6c7f | |||
| 8b5fa347cb | |||
| b22123bdfc | |||
| 8076cbbd51 | |||
| c8ed098bfc | |||
| 71ef310648 | |||
| e3f288083c | |||
| d44e93dd4f | |||
| 4db07bea59 | |||
| befcfcd282 | |||
| 85733e870c | |||
| 7a5795e131 | |||
| 46654a05b1 | |||
| 085652c2dd | |||
| fb4710b011 | |||
| 5dd58b8297 | |||
| 4724741fdd | |||
| 9b08bd8bfd | |||
| adec664c52 | |||
| e222a93204 | |||
| 51be04e5d2 | |||
| 58d99259e8 | |||
| 2065122777 | |||
| 5670e5138c | |||
| 5e9d9f00e8 | |||
| e155682b5f | |||
| 91840ca2ca | |||
| d000fce423 | |||
| ac4236b03f | |||
| 223001d8a2 | |||
| 3a32429a5f | |||
| 18575d1c55 | |||
| d62304b2c4 | |||
| 9fd0116dec | |||
| ab9b132c69 | |||
| 1b0f906640 | |||
| 012b220c60 | |||
| 19203c050c | |||
| 58327a9a66 | |||
| ca98967af0 | |||
| b74dccd805 | |||
| cd7f6668af | |||
| 5453ce1dcf | |||
| c0e338eb23 | |||
| 492ca9d82e | |||
| de62f0779b | |||
| 57f792fbe5 | |||
| 2854eb97bb | |||
| 1488fc42e1 | |||
| 557c7dbda0 | |||
| 2bcae30378 | |||
| 970bea5222 | |||
| 6718e5e2cc | |||
| 30900dc3ad | |||
| 0947b73a1a | |||
| aaf7d0afd1 | |||
| 77ffb150c6 | |||
| 73ff3f7089 | |||
| 8fb3949fac | |||
| dce017fbf2 | |||
| 58e3e98031 | |||
| e65661ef94 | |||
| 5e88a1ce24 | |||
| 67ae4fe038 | |||
| 561f3332b4 | |||
| c628e208ca | |||
| 273924a36a | |||
| ff53290a57 | |||
| 7589e942eb |
+2
-1
@@ -1,3 +1,4 @@
|
||||
data/
|
||||
data/*
|
||||
!data/ft.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.13.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# FG2609 席位资金分析报告(2026-07-20 → 2026-07-24)
|
||||
|
||||
---
|
||||
|
||||
## 席位分类
|
||||
|
||||
| 分类 | 席位 |
|
||||
|------|------|
|
||||
| 家人(散户) | 东方财富、徽商期货、方正中期、中信建投、平安期货 |
|
||||
| 大户(产业/套保) | 永安期货、中信期货、国泰君安、海通期货、华泰期货、高盛期货、乾坤期货、摩根大通、中粮期货 |
|
||||
| 噪音(忽略) | 东证期货 |
|
||||
| 其他 | 未归入以上三类的席位 |
|
||||
|
||||
---
|
||||
|
||||
## 一、家人席位动向(散户情绪)
|
||||
|
||||
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|
||||
|------|---------|---------|----------|
|
||||
| 07-20 | -0.66万 | +0.75万 | -1.41万 |
|
||||
| 07-21 | -3.38万 | -0.04万 | -3.34万 |
|
||||
| 07-22 | -1.60万 | +0.50万 | -2.10万 |
|
||||
| 07-23 | +0.83万 | -0.44万 | +1.27万 |
|
||||
| 07-24 | -1.89万 | -0.30万 | -1.59万 |
|
||||
|
||||
**核心结论:散户持续看空,减多为主。**
|
||||
|
||||
- 5 日累计减多 **-6.69 万**,累计加空 **+0.46 万**,净多变化 **-7.15 万**。
|
||||
- 唯一一天净多(07-23)量级很小(+1.27 万),第二天立刻转回净空,说明散户整体偏空且缺乏持续性信心。
|
||||
- 07-21 单日减多 **-3.38 万**,是当周家人席位减多最大的一天。这一天跳空低开、放量下杀,散户在恐慌中止损多单——典型的追跌杀多行为。
|
||||
- 家人席位里,方正中期、中信建投、徽商期货、东方财富、平安期货五个席位多单在 5 日内全部下降,空单方向仅中信建投在 07-20 和 07-22 有加空动作。
|
||||
|
||||
**定性:散户情绪整体偏空,在下跌中减多出逃,属于典型的情绪化交易。**
|
||||
|
||||
---
|
||||
|
||||
## 二、大户席位动向(产业/套保资金)
|
||||
|
||||
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|
||||
|------|---------|---------|----------|
|
||||
| 07-20 | +0.13万 | -1.26万 | +1.39万 |
|
||||
| 07-21 | +2.14万 | -1.93万 | +4.07万 |
|
||||
| 07-22 | -2.10万 | -6.67万 | +4.57万 |
|
||||
| 07-23 | -0.52万 | -1.56万 | +1.04万 |
|
||||
| 07-24 | -0.29万 | -1.50万 | +1.21万 |
|
||||
|
||||
**核心结论:大户持续大规模减空,是本周最重要的资金信号。**
|
||||
|
||||
- 5 日累计减空 **-12.93 万**,减多仅 **-0.65 万**,净多变化 **+12.28 万**。
|
||||
- 每天都是净多方向(大户每一天的空单减持量都大于多单减持量),全周无一天例外。
|
||||
- **07-22(周三)是标志性节点**:大户单日减空 **-6.67 万**,是当周最大单日空头撤离。当天高盛减空 -1.49 万、中信减空 -2.20 万、国泰君安减空 -0.93 万,三家头部大户同步砍空单。
|
||||
|
||||
### 关键大户席位拆解
|
||||
|
||||
**空头三巨头(中信、高盛、国泰君安):**
|
||||
|
||||
| 席位 | 07-20 空单 | 07-24 空单 | 5日减空 |
|
||||
|------|-----------|-----------|---------|
|
||||
| 中信期货 | 21.61万 | 18.07万 | **-3.54万** |
|
||||
| 高盛期货 | 21.93万 | 17.81万 | **-4.12万** |
|
||||
| 国泰君安 | 18.81万 | 17.47万 | **-1.34万** |
|
||||
|
||||
三家合计减空 **9.00 万**,占了全部大户减空量的 70%。这不是散户式的小打小闹,是主力资金在系统性地撤退空头头寸。
|
||||
|
||||
**定性:大户资金明确偏多。产业/套保盘的空头大幅回补,说明产业端认为当前价位已不具备继续做空的安全边际,或是交割月临近主动平空离场。**
|
||||
|
||||
---
|
||||
|
||||
## 三、多空力量对比
|
||||
|
||||
| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空)| 方向 |
|
||||
|------|------------|------------|-------------|------|
|
||||
| 家人 | -6.69万 | +0.46万 | **-7.15万** | 偏空 |
|
||||
| 大户 | -0.65万 | -12.93万 | **+12.28万** | 强烈偏多 |
|
||||
| 其他 | -4.24万 | -3.63万 | **-0.62万** | 略偏空 |
|
||||
| **合计** | -11.58万 | -16.10万 | **+4.52万** | 偏多 |
|
||||
|
||||
**核心结论:大户的净多力量(+12.28 万)远超家人(-7.15 万)和其他席位(-0.62 万)的净空力量之和。**
|
||||
|
||||
- 全市场合计净多 **+4.52 万**,由大户独力支撑。
|
||||
- 家人与其他席位合计净空 **-7.77 万**,与大户形成明确对手盘——大户在接散户抛出的多单、同时平掉自己的空单,典型的"聪明钱吃傻钱"格局。
|
||||
- 东证期货被定义为噪音席位已排除,不影响结论。
|
||||
|
||||
---
|
||||
|
||||
## 四、异常信号
|
||||
|
||||
### 1. 07-22(周三)空头集体溃退
|
||||
|
||||
大户单日减空 **-6.67 万**,是 5 日均值(-2.59 万)的 2.6 倍。中信 -2.20 万、高盛 -1.49 万、国泰 -0.93 万同时大幅减空。当天成交量 Top 1 的国泰君安成交达 38.13 万手(本周最高),放量明显。这一天出现了明确的空头踩踏信号。
|
||||
|
||||
### 2. 07-21(周二)散户与大户严重分歧
|
||||
|
||||
家人减多 **-3.38 万**(全周最大单日减多),大户加多 **+2.14 万**(全周最大单日加多)。一方恐慌出逃,一方逆势加仓。这是本周最典型的"对手盘"交易日。如果当天价格下跌,则大户在低位吸筹;如果价格上涨,则散户在反弹中解套离场——无论哪种,都是大户占优。
|
||||
|
||||
### 3. 冠通期货(其他席位)异动
|
||||
|
||||
07-22 成交 +4.23 万(从名不见经传到跻身 Top 13),07-23 再度 +4.78 万成交并进入 Top 6。这个席位两天成交暴增近 10 万手,背后可能有大资金借道入场,值得后续关注。
|
||||
|
||||
### 4. 永安期货多单逆势增加
|
||||
|
||||
永安 5 日多单从 4.38 万 → 4.53 万 → 4.56 万 → 4.99 万 → 5.19 万,累计加多 **+0.81 万**。同时空单从 6.41 万减至 4.62 万(-1.79 万)。永安的"多增空减"在大户中最为突出,是本周方向最明确的偏多席位。
|
||||
|
||||
---
|
||||
|
||||
## 五、行情预判
|
||||
|
||||
### 判断:偏多,短期看反弹
|
||||
|
||||
**理由:**
|
||||
|
||||
1. **大户资金面强烈偏多**:5 日净多 +12.28 万,头部空头(中信、高盛、国泰)合计减空 9 万手,空头回补动能远未耗尽。如果这些席位还有空头敞口,下周可能继续平空,形成持续买盘。
|
||||
|
||||
2. **散户是反向指标**:家人席位 5 日净空 -7.15 万,在下跌中恐慌减多,符合散户在底部区域的行为特征。散户出清往往是阶段性底部的必要条件。
|
||||
|
||||
3. **对手盘格局明显**:大户接散户的盘,且大户是主动方(减空 + 稳多),散户是被动方(减多),这是典型的筑底资金结构。
|
||||
|
||||
4. **永安、国泰君安等头部席位方向一致**:多家大户多单稳定、空单持续下降,资金面没有分歧。
|
||||
|
||||
### 风险提示
|
||||
|
||||
- 全周减多总量(-11.58 万)也很大,说明多头信心整体不足。大户更多是"不空了"而非"大力做多",反弹力度取决于后续是否有新增多单入场。
|
||||
- 冠通期货的异常放量可能代表新的空头力量进场,需要观察其后续持仓方向。
|
||||
- 如果下一交易日价格反弹后大户开始减多,则说明这只是一轮空头回补行情,而非趋势反转。
|
||||
|
||||
**综合判断:偏多(概率 65%),预计 FG2609 下一交易日震荡偏强,关注空头回补持续性和是否有增量多头入场。**
|
||||
@@ -0,0 +1,133 @@
|
||||
# FG2611 席位资金分析报告(2026-07-20 → 2026-07-24)
|
||||
|
||||
---
|
||||
|
||||
## 席位分类
|
||||
|
||||
| 分类 | 席位 |
|
||||
|------|------|
|
||||
| 家人(散户) | 东方财富、徽商期货、方正中期、中信建投、平安期货 |
|
||||
| 大户(产业/套保) | 永安期货、中信期货、国泰君安、海通期货、华泰期货、高盛期货、乾坤期货、摩根大通、中粮期货 |
|
||||
| 噪音(忽略) | 东证期货 |
|
||||
| 其他 | 未归入以上三类的席位 |
|
||||
|
||||
---
|
||||
|
||||
## 一、家人席位动向(散户情绪)
|
||||
|
||||
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|
||||
|------|---------|---------|----------|
|
||||
| 07-20 | -0.06万 | -0.15万 | +0.09万 |
|
||||
| 07-21 | -0.00万 | +0.01万 | -0.01万 |
|
||||
| 07-22 | +0.07万 | -0.04万 | +0.11万 |
|
||||
| 07-23 | +0.02万 | -0.06万 | +0.08万 |
|
||||
| 07-24 | -0.13万 | -0.06万 | -0.07万 |
|
||||
|
||||
**核心结论:家人席位参与度极低,无明确方向性。**
|
||||
|
||||
- 5 日累计减多 **-0.10 万**,累计减空 **-0.30 万**,净多变化 **+0.20 万**。
|
||||
- 每日变动量级在数百手级别,与 FG2609 主连合约上万手的波动完全不同。FG2611 是远月合约,散户参与度天然较低。
|
||||
- 无追涨杀跌迹象——单日最大变动仅 -0.13 万(07-24 减多),远不足以构成情绪判断。
|
||||
- 家人持仓绝对量也极小:中信建投是唯一多空持仓均超 2 万的家人席位,东方财富多单仅 0.32 万、空单未进 Top 20。
|
||||
|
||||
**定性:远月合约散户参与度极低,家人席位资金信号不具备参考价值。**
|
||||
|
||||
---
|
||||
|
||||
## 二、大户席位动向(产业/套保资金)
|
||||
|
||||
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|
||||
|------|---------|---------|----------|
|
||||
| 07-20 | -0.68万 | -0.31万 | -0.37万 |
|
||||
| 07-21 | -0.10万 | +0.14万 | -0.24万 |
|
||||
| 07-22 | -0.28万 | +0.20万 | -0.48万 |
|
||||
| 07-23 | +0.98万 | +0.42万 | +0.56万 |
|
||||
| 07-24 | +0.41万 | +0.30万 | +0.11万 |
|
||||
|
||||
**核心结论:前三天减多偏空,后两天显著反转加多,方向切换明显。**
|
||||
|
||||
- 5 日累计加多 **+0.33 万**,加空 **+0.76 万**,净多变化 **-0.43 万**。总体略偏空,但主要是前三天贡献的。
|
||||
- **07-23(周四)是转折点**:大户加多 +0.98 万,是 5 日最大单日加多。华泰期货加多 +0.74 万(从 0.18 万跳升至 0.92 万,增量惊人)、中粮期货加多 +0.32 万。
|
||||
- 空头方面,高盛期货持续加空:从 07-20 的 1.05 万 → 07-21 的 1.20 万 → 07-22 的 1.76 万 → 07-23 的 1.91 万 → 07-24 的 2.01 万,5 日累计加空 **+0.96 万**,是大户中唯一持续单向加空的席位。
|
||||
- 国泰君安 07-24 多单 +0.26 万、空单 +0.32 万,双边大幅加仓,方向不明确但参与度在提升。
|
||||
|
||||
### 关键大户席位拆解
|
||||
|
||||
**中粮期货——本周最活跃的大户席位:**
|
||||
|
||||
| 指标 | 07-20 | 07-21 | 07-22 | 07-23 | 07-24 |
|
||||
|------|-------|-------|-------|-------|-------|
|
||||
| 成交量 | 5.05万 | 2.81万 | 5.62万 | 7.48万 | 1.65万 |
|
||||
| 多单 | 1.48万 | 1.16万 | 1.43万 | 1.75万 | 1.79万 |
|
||||
| 空单 | 1.46万 | 1.14万 | 1.39万 | 1.73万 | 1.73万 |
|
||||
|
||||
中粮在 FG2611 上多空双向加仓,多单从 1.48 万增至 1.79 万(+0.31 万),空单从 1.46 万增至 1.73 万(+0.27 万)。**这是典型的套利/套保盘口——多空同步增加,而非单纯做方向。**
|
||||
|
||||
**华泰期货 07-23 异动:**
|
||||
|
||||
华泰多单从 0.18 万(07-22)暴增至 0.92 万(07-23),单日加多 **+0.74 万**,增幅超过 400%。同时成交量从 0.42 万放大至 1.22 万。这是一个非常突兀的增仓,值得重点关注后续是否持续。
|
||||
|
||||
**定性:大户整体略偏空(净多 -0.43 万),但后半周出现方向切换迹象。高盛是唯一坚定的空头加仓者,中粮以套利盘为主,华泰异动值得追踪。**
|
||||
|
||||
---
|
||||
|
||||
## 三、多空力量对比
|
||||
|
||||
| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空)| 方向 |
|
||||
|------|------------|------------|-------------|------|
|
||||
| 家人 | -0.10万 | -0.30万 | **+0.20万** | 微偏多 |
|
||||
| 大户 | +0.33万 | +0.76万 | **-0.43万** | 略偏空 |
|
||||
| 其他 | -0.72万 | -1.20万 | **+0.48万** | 偏多 |
|
||||
| **合计** | -0.49万 | -0.74万 | **+0.25万** | 微偏多 |
|
||||
|
||||
**核心结论:FG2611 整体资金量级较小,市场方向不明确。**
|
||||
|
||||
- 全市场净多仅 **+0.25 万**,与 FG2609 的 +4.52 万形成鲜明对比。远月合约的资金博弈强度远低于主连。
|
||||
- 三大分类之间无明显对手盘关系——家人偏多(+0.20 万)、大户偏空(-0.43 万)、其他偏多(+0.48 万),但量级都太小,无法形成有效信号。
|
||||
- 其他席位(+0.48 万)贡献了最大的净多力量,但这些席位分散、方向一致性弱。
|
||||
|
||||
**定性:远月合约流动性差,各类资金均未展现出明确的战略方向,不宜据此做出方向判断。**
|
||||
|
||||
---
|
||||
|
||||
## 四、异常信号
|
||||
|
||||
### 1. 07-23(周四)大户方向逆转
|
||||
|
||||
前半周大户减多为主(三日累计 -1.06 万),07-23 突然加多 +0.98 万。华泰期货一天加多 +0.74 万是主要推手。这种突然的方向切换值得警惕——可能是某个产业户判断价格到位后集中建仓多单。
|
||||
|
||||
### 2. 高盛期货持续单向加空
|
||||
|
||||
高盛空单每日递增:1.05 → 1.20 → 1.76 → 1.91 → 2.01 万,5 日累计 +0.96 万,且多单从不在 Top 20。在所有大户席位中,高盛是唯一一个方向完全一致的席位——**只做空、不加多**。这在 FG2609 高盛大规模减空的背景下显得尤为突兀:主连上高盛在撤退,远月上高盛在加码。需要关注这是跨期套利(空 11 多 09)还是纯粹的远月看空。
|
||||
|
||||
### 3. 07-24(周五)中粮成交量骤降
|
||||
|
||||
中粮成交量从 07-23 的 7.48 万骤降至 07-24 的 1.65 万(-5.82 万),降幅居全席位之首。而当天中粮多空持仓几乎未变(多 +0.04 万、空 +0.00 万)。这说明中粮在周四大幅建仓后,周五选择锁仓不动,符合套保盘的交易特征。
|
||||
|
||||
### 4. 华泰期货多单暴增
|
||||
|
||||
07-23 华泰多单从 0.18 万跳升至 0.92 万(+0.74 万),而 07-22 华泰多单甚至不在 Top 20(意味持仓低于 0.27 万)。也就是说在两天内华泰从几乎零多单变成了近万手多单。这是一个需要持续追踪的信号——如果后续继续加仓,可能预示产业资金在远月布局。
|
||||
|
||||
---
|
||||
|
||||
## 五、行情预判
|
||||
|
||||
### 判断:震荡,方向不明
|
||||
|
||||
**理由:**
|
||||
|
||||
1. **资金量级太小**:全周净多仅 +0.25 万,各项资金变动均不足以支撑方向判断。远月合约天然流动性差,席位分析的有效性大幅下降。
|
||||
|
||||
2. **大户内部存在分歧**:高盛持续单向加空(+0.96 万),但华泰(+0.74 万)和中粮(+0.31 万)在加多。同为产业/套保大户,方向截然相反,说明产业端对远月定价存在明显分歧。
|
||||
|
||||
3. **与 FG2609 的方向背离**:FG2609 大户强烈偏多(净多 +12.28 万),而 FG2611 大户略偏空(净多 -0.43 万)。高盛在 2609 上减空 -4.12 万,在 2611 上加空 +0.96 万——这可能是跨期套利,而非单纯看多或看空。
|
||||
|
||||
4. **缺乏家人席位验证**:远月合约散户参与度极低,无法通过"家人—大户对手盘"框架来验证信号。缺少了一个重要的判断维度。
|
||||
|
||||
### 参考建议
|
||||
|
||||
- **以 FG2609 为主、FG2611 为辅**:主连合约资金博弈充分,信号更可靠。FG2611 当前阶段更适合作为跨期价差分析的参考,而非独立的方向判断依据。
|
||||
- **重点关注华泰期货后续动向**:如果华泰在 FG2611 上继续加多,同时 FG2609 方向一致,则强化偏多判断。
|
||||
- **关注高盛的跨期行为**:高盛在 2609 减空、2611 加空,如果这一模式持续,可能是"近强远弱"的跨期结构信号。
|
||||
|
||||
**综合判断:FG2611 自身资金面无法给出有效方向,建议以 FG2609 的分析结论为主(偏多),FG2611 跟随主连波动。远月合约暂以震荡看待。**
|
||||
@@ -0,0 +1,166 @@
|
||||
# FG2701 席位资金分析报告(2026-07-20 → 2026-07-24)
|
||||
|
||||
---
|
||||
|
||||
## 席位分类
|
||||
|
||||
| 分类 | 席位 |
|
||||
|------|------|
|
||||
| 家人(散户) | 东方财富、徽商期货、方正中期、中信建投、平安期货 |
|
||||
| 大户(产业/套保) | 永安期货、中信期货、国泰君安、海通期货、华泰期货、高盛期货、乾坤期货、摩根大通、中粮期货 |
|
||||
| 噪音(忽略) | 东证期货 |
|
||||
| 其他 | 未归入以上三类的席位 |
|
||||
|
||||
---
|
||||
|
||||
## 一、家人席位动向(散户情绪)
|
||||
|
||||
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|
||||
|------|---------|---------|----------|
|
||||
| 07-20 | +0.08万 | +0.61万 | -0.53万 |
|
||||
| 07-21 | +0.06万 | 0.00万 | +0.06万 |
|
||||
| 07-22 | +0.20万 | +0.45万 | -0.25万 |
|
||||
| 07-23 | +0.10万 | -0.22万 | +0.32万 |
|
||||
| 07-24 | -0.02万 | -0.18万 | +0.16万 |
|
||||
|
||||
**核心结论:家人席位多空双增,但空单增幅更大,整体略偏空。**
|
||||
|
||||
- 5 日累计加多 **+0.43 万**,加空 **+0.65 万**,净多变化 **-0.23 万**。
|
||||
- 前半周(07-20、07-22)偏空,后半周(07-23、07-24)偏多,方向摇摆不定。
|
||||
- **07-20(周一)是家人加空最多的一天**:+0.61 万,全部来自中信建投单日加空 **+0.56 万**。中信建投空单从 0.80 万跳升至 1.50 万(07-22 最高点),后半周又回落到 1.07 万。这不是散户行为,更像是中信建投自营或大户借道这个席位做空了远月,因为散户不太可能一天加 5600 手空单。
|
||||
- 方正中期是家人中多单最大的席位(1.03→1.03→1.08→1.03→1.06 万),5 日几乎未变,方向中性。
|
||||
- 东方财富和平安期货持仓量极小(0.3 万级别),纯散户参与度极低。
|
||||
|
||||
**定性:FG2701 远月合约家人席位参与度低,且中信建投的"家人"分类在此合约上可能失真——其 07-20 单日加空 0.56 万的行为更像是大户级的操作。**
|
||||
|
||||
---
|
||||
|
||||
## 二、大户席位动向(产业/套保资金)
|
||||
|
||||
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|
||||
|------|---------|---------|----------|
|
||||
| 07-20 | +0.15万 | +0.27万 | -0.12万 |
|
||||
| 07-21 | -0.76万 | 0.00万 | -0.76万 |
|
||||
| 07-22 | -0.25万 | -0.28万 | +0.03万 |
|
||||
| 07-23 | -0.24万 | +0.68万 | -0.92万 |
|
||||
| 07-24 | +0.12万 | -0.16万 | +0.28万 |
|
||||
|
||||
**核心结论:大户显著偏空,是三个合约中大户方向最明确的空头。**
|
||||
|
||||
- 5 日累计减多 **-0.97 万**,加空 **+0.51 万**,净多变化 **-1.48 万**。
|
||||
- 5 天中有 3 天净空,且 07-21(-0.76 万)和 07-23(-0.92 万)两天的净空量级明显大于另外三天的波动幅度。
|
||||
- 与 FG2609(大户净多 +12.28 万)和 FG2611(大户净多 -0.43 万)形成渐进式对比:**越远的合约,大户越偏空**。
|
||||
|
||||
### 关键大户席位拆解
|
||||
|
||||
**国泰君安——FG2701 的"超级空头":**
|
||||
|
||||
| 指标 | 07-20 | 07-21 | 07-22 | 07-23 | 07-24 |
|
||||
|------|-------|-------|-------|-------|-------|
|
||||
| 多单 | 1.40万 | 1.43万 | 1.50万 | 1.32万 | 1.39万 |
|
||||
| 空单 | **4.42万** | — | 4.15万 | **4.46万** | **4.54万** |
|
||||
|
||||
国泰君安的空单(4.54 万)是它多单(1.39 万)的 **3.3 倍**,占大户空单总量的近 50%,占全市场空单的约 30%。在 FG2701 这个流动性远不如主连的远月合约上,单一席位持有如此庞大的空头敞口,非常不寻常。
|
||||
|
||||
这个席位在三个合约上的行为完全不同:
|
||||
- FG2609:空单 18.81 → 17.47 万(减空 -1.34 万),**偏多**
|
||||
- FG2611:多空双向,方向模糊
|
||||
- FG2701:空单 4.42 → 4.54 万(加空 +0.12 万),**坚定偏空**
|
||||
|
||||
**这很可能不是同一类资金在操作三个合约**。FG2701 的国泰君安空头大概率是产业套保盘——玻璃企业锁定明年 1 月的卖出价格。
|
||||
|
||||
**高盛期货——锁仓不动:**
|
||||
|
||||
高盛空单全周维持在 1.70-1.71 万,几乎零变动。对比 FG2609 上高盛大幅减空 4.12 万、FG2611 上加空 0.96 万,FG2701 上的高盛选择了按兵不动。
|
||||
|
||||
**华泰期货多单持续缩水:**
|
||||
|
||||
华泰多单从 07-20 的 1.63 万 → 07-21 的 0.92 万(-0.71 万)→ 07-22/23/24 维持 0.87-0.91 万。华泰曾是 FG2701 上最大的多头,但 07-21 一天就砍掉了近一半的多单。这是一个明确的减多信号。
|
||||
|
||||
**永安期货多减空增:**
|
||||
|
||||
永安多单从 0.89 万 → 0.85 → 0.41 → 0.42 → 0.46 万(减多 -0.43 万),空单从 0.88 → 无 → 1.02 → 1.32 → 1.06 万(加空 +0.18 万)。方向明确偏空,与国泰君安形成呼应。
|
||||
|
||||
**定性:大户在 FG2701 上呈现一致的偏空布局。国泰君安的 4.54 万天量空单是核心信号,永安、华泰(减多方面)也指向同一方向。高盛虽然锁仓不动,但其在 FG2611 上的加空行为暗示整体偏空倾向。**
|
||||
|
||||
---
|
||||
|
||||
## 三、多空力量对比
|
||||
|
||||
| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空)| 方向 |
|
||||
|------|------------|------------|-------------|------|
|
||||
| 家人 | +0.43万 | +0.65万 | **-0.23万** | 略偏空 |
|
||||
| 大户 | -0.97万 | +0.51万 | **-1.48万** | 明确偏空 |
|
||||
| 其他 | +1.01万 | +1.77万 | **-0.75万** | 偏空 |
|
||||
| **合计** | +0.47万 | +2.93万 | **-2.46万** | 明确偏空 |
|
||||
|
||||
**核心结论:FG2701 全市场三大分类一致偏空,是本周三个合约中方向最统一的空头合约。**
|
||||
|
||||
- 全市场净空 **-2.46 万**,与 FG2609 的 +4.52 万净多形成 180 度背离。
|
||||
- 其他席位是最大的空头增量来源(+1.77 万空单),但分散在多个席位上,方向一致性不如大户。
|
||||
- 没有人做多:大户减多(-0.97 万)而非加多,其他席位加多(+1.01 万)但加空更多(+1.77 万),家人加多(+0.43 万)但加空同样更多(+0.65 万)。
|
||||
|
||||
### 三大合约方向对比
|
||||
|
||||
| 合约 | 大户净多 | 总净多 | 方向 |
|
||||
|------|---------|--------|------|
|
||||
| FG2609(近月) | +12.28万 | +4.52万 | **强烈偏多** |
|
||||
| FG2611(次月) | -0.43万 | +0.25万 | 震荡 |
|
||||
| FG2701(远月) | -1.48万 | -2.46万 | **明确偏空** |
|
||||
|
||||
**这构成了一条清晰的期限结构:近强远弱,越远期越偏空。** 这符合商品期货在 contango/backwardation 结构下的常见特征——近月受现货支撑偏强,远月因产业套保盘(玻璃厂卖出远期产量)而承压。
|
||||
|
||||
**定性:FG2701 全市场一致偏空,核心驱动力是大户套保空头(国泰君安 4.54 万)和其他席位的分散加空。这并非市场情绪的"看空",更像是产业资金利用远月合约进行卖出套保的正常操作。**
|
||||
|
||||
---
|
||||
|
||||
## 四、异常信号
|
||||
|
||||
### 1. 国泰君安的"超级空头"是 FG2701 的定价锚
|
||||
|
||||
一个席位在远月合约上持有 4.54 万空单、多单仅 1.39 万,净空 3.15 万。这个量级在整个 FG2701 市场中占比极大。**如果国泰君安这个空头开始平仓(买回),会对价格形成巨大上推力。** 这是 FG2701 最大的潜在多空催化剂。
|
||||
|
||||
### 2. 07-24(周五)其他席位空单集中暴增
|
||||
|
||||
其他席位空单 07-24 单日 +1.33 万,是 5 日最大单日增幅。主要贡献者:
|
||||
- 国投期货空单 +0.54 万(从 0.39 万跳至 0.93 万)
|
||||
- 广发期货空单 +0.46 万(从 0.38 万跳至 0.84 万)
|
||||
- 瑞达期货空单 +0.23 万(从 0.68 万增至 0.91 万)
|
||||
|
||||
三家合计加空 1.23 万。周五通常是减仓日(规避周末风险),但这三家反而在周五尾盘加空,值得关注。
|
||||
|
||||
### 3. 07-21(周二)华泰多单崩盘式减仓
|
||||
|
||||
华泰期货多单从 1.63 万暴跌至 0.92 万(-0.71 万),单日减幅 44%。当天华泰成交量 1.62 万(较前一日的 0.58 万放大近 3 倍),但多单大减、空单未变——说明是单向平多出逃。作为 FG2701 原本的第一大多头,华泰的突然撤退是重要的偏空信号。
|
||||
|
||||
### 4. 中信建投的异常空单操作(07-20 和 07-22)
|
||||
|
||||
中信建投空单:07-20 为 0.80 万,07-20 当天 +0.56 万 → 07-22 继续增至 1.50 万 → 07-23 降至 1.32 万 → 07-24 再降至 1.07 万。先猛加后缓减,这种操作模式更像是机构行为而非散户行为。将其归入"家人"分类在 FG2701 上可能不准确。
|
||||
|
||||
---
|
||||
|
||||
## 五、行情预判
|
||||
|
||||
### 判断:偏空,但不宜追空(空头已高度拥挤)
|
||||
|
||||
**理由:**
|
||||
|
||||
1. **方向一致但过于拥挤**:全市场三大分类全部偏空,总净空 -2.46 万。国泰君安一家就净空 3.15 万。当一个方向上所有人都已入场,反向的边际力量反而更大——任何风吹草动都可能触发空头回补。
|
||||
|
||||
2. **产业套保逻辑清晰**:FG2701 对应明年 1 月交割,是玻璃消费淡季。玻璃厂在远月合约做卖出套保(锁定销售价格)是合理的产业行为。国泰君安的 4.54 万空单大概率是产业客户的套保盘,这类资金通常持有到期,不会频繁交易。所以这 4.54 万更像是"压舱石"而非"投机空头"。
|
||||
|
||||
3. **跨期结构已定价**:近强远弱(FG2609 强多 → FG2701 强空)的期限结构已经非常清晰。如果你已经做空 FG2701,这个结构已经 price in。真正的边际变化要看:
|
||||
- 近月 FG2609 的空头回补能否带动远月跟涨
|
||||
- 国泰君安是否会在 FG2701 上继续加空
|
||||
|
||||
4. **周五空头集中加仓可能是尾部信号**:07-24 其他席位集中加空 +1.33 万,如果这是新空头的"最后一波"入场,那么空头动能可能即将衰竭。
|
||||
|
||||
### 情景推演
|
||||
|
||||
| 情景 | 触发条件 | FG2701 走势 |
|
||||
|------|---------|------------|
|
||||
| 基准(60%) | FG2609 继续反弹,国泰君安维持空单 | 震荡偏弱,跟随主连但涨幅受限 |
|
||||
| 空头回补(25%) | FG2609 大幅反弹,国泰君安开始平 FG2701 空单 | 涨幅可能超过 FG2609(空头踩踏) |
|
||||
| 继续加空(15%) | 市场情绪恶化,产业端加大套保力度 | 独立走弱,跌幅大于 FG2609 |
|
||||
|
||||
**综合判断:偏空(概率 60%),但风险收益比已不适合追空。FG2701 的空头过于拥挤,一旦触发回补行情,反弹速度和幅度可能超预期。建议以观望为主,若持有空单则设好止盈,若空仓则等待更明确的信号。FG2609 的偏多判断和 FG2701 的偏空判断并不矛盾——它们反映的是同一品种不同到期月份的合理价差结构。**
|
||||
@@ -0,0 +1,123 @@
|
||||
# 玻璃期货综合持仓与成交量分析(2026-07-27 盘前)
|
||||
|
||||
基于 FG2609/FG2611/FG2701 三份席位分析报告,对下个交易日(07-27 周一)的行情推演。
|
||||
|
||||
---
|
||||
|
||||
## 一、三合约核心数据总览
|
||||
|
||||
| 指标 | FG2609(主连) | FG2611(次月) | FG2701(远月) |
|
||||
|------|:------------:|:------------:|:------------:|
|
||||
| 大户 5 日净多 | **+12.28 万** | -0.43 万 | -1.48 万 |
|
||||
| 家人 5 日净多 | -7.15 万 | +0.20 万 | -0.23 万 |
|
||||
| 全市场净多 | **+4.52 万** | +0.25 万 | **-2.46 万** |
|
||||
| 方向 | **强烈偏多** | 震荡 | 明确偏空 |
|
||||
| 主力信号 | 空头踩踏回补 | 流动性不足 | 产业套保压盘 |
|
||||
|
||||
**期限结构清晰:近强远弱,逐月递减。FG2609 是行情发动机,FG2611 跟随,FG2701 独立承压。**
|
||||
|
||||
---
|
||||
|
||||
## 二、交易量分析:放量集中在近月,空头回补是主要成交来源
|
||||
|
||||
**FG2609 成交量是核心。** 07-22(周三)国泰君安单日成交 38.13 万手,为全周最高,当天大户减空 -6.67 万手,中信 -2.20 万、高盛 -1.49 万、国泰 -0.93 万同步砍空。这是典型的**空头踩踏式放量**——主要成交不是新增多头在买,而是老空头在抢着平仓。
|
||||
|
||||
FG2611 的成交也佐证了"量在近月":中粮期货 07-23 成交 7.48 万手(周最高),07-24 骤降至 1.65 万手后锁仓不动,套保盘集中建仓后收手。
|
||||
|
||||
**FG2701 周五出现反常放量**——其他席位(国投 +0.54 万、广发 +0.46 万、瑞达 +0.23 万)合计加空 1.23 万手。周五通常是避险减仓日,此时加空说明有空头在押注周一继续下跌,这构成周一潜在的**空头拥挤风险**。
|
||||
|
||||
---
|
||||
|
||||
## 三、持仓结构分析:三合约构成跨期对手盘矩阵
|
||||
|
||||
**最核心的逻辑不在单个合约内部,而在三个合约之间的资金联动:**
|
||||
|
||||
### 高盛期货——跨期套利布局
|
||||
|
||||
| 合约 | 行为 | 5 日变动 |
|
||||
|------|------|---------|
|
||||
| FG2609 | **大幅减空** | -4.12 万 |
|
||||
| FG2611 | **持续加空** | +0.96 万 |
|
||||
| FG2701 | 锁仓不动 | ~0 |
|
||||
|
||||
高盛在做 **"多近月、空远月"的跨期套利**,不是单边看多或看空。它认为近月(09)被低估、远月(11)相对高估,近强远弱的价差会继续扩大。
|
||||
|
||||
### 国泰君安——三合约三个角色
|
||||
|
||||
| 合约 | 角色 | 持仓特征 |
|
||||
|------|------|---------|
|
||||
| FG2609 | 空头回补者 | 空单 18.81→17.47 万,减空 -1.34 万 |
|
||||
| FG2611 | 双边加仓 | 多空同步增加,方向模糊 |
|
||||
| FG2701 | **超级空头** | 空单 4.54 万,是多单的 3.3 倍 |
|
||||
|
||||
国泰君安在 FG2701 上 4.54 万空单占全市场约 30%,这大概率是产业套保盘(玻璃厂锁定明年 1 月卖出价格),持有到期、不频繁交易,是 FG2701 的长期压舱石。
|
||||
|
||||
### 永安期货——唯一的多空同向偏多席位
|
||||
|
||||
永安在 FG2609 上:多单 5 日递增(4.38→4.53→4.56→4.99→5.19 万,+0.81 万),空单递减(6.41→4.62 万,-1.79 万)。**多增空减,在所有大户中方向最干净、最统一。**
|
||||
|
||||
### 空头三巨头剩余敞口
|
||||
|
||||
| 席位 | 07-24 空单 | 5 日减空 | 剩余空头敞口 |
|
||||
|------|-----------|---------|------------|
|
||||
| 中信期货 | 18.07 万 | -3.54 万 | 仍很大 |
|
||||
| 高盛期货 | 17.81 万 | -4.12 万 | 仍很大 |
|
||||
| 国泰君安 | 17.47 万 | -1.34 万 | 仍很大 |
|
||||
| **合计** | **53.35 万** | **-9.00 万** | — |
|
||||
|
||||
三家合计仍持有 53.35 万空单,上周只减了 9 万。**如果反弹延续,空头回补的买盘还远未耗尽。**
|
||||
|
||||
---
|
||||
|
||||
## 四、下个交易日(07-27)行情推演
|
||||
|
||||
### FG2609(主连):偏多(概率 65%),看反弹延续
|
||||
|
||||
**做多力量来源:**
|
||||
|
||||
1. **空头回补动能远未耗尽**:三大空头仍有 53.35 万敞口。反弹延续 → 空头继续砍仓 → 形成持续买盘的逻辑链条仍在。
|
||||
2. **永安在持续加多**:唯一在价格低位连续 5 日递增多单的大户,方向最明确。
|
||||
3. **散户是反向指标**:家人 5 日净空 -7.15 万,在下跌中止损多单。散户出清 + 大户接盘 = 典型的筑底资金结构。
|
||||
|
||||
**抑制上涨的因素:**
|
||||
|
||||
- 大户更多是"不空了"而非"大力做多"(减空 -12.93 万 vs 减多仅 -0.65 万)。空头回补驱动的反弹,一旦空头平完、没有新增多头接力,反弹就会熄火。
|
||||
- 冠通期货连续两天放量(+9 万手),持仓方向未知,可能是新的空头力量入场。
|
||||
|
||||
**情景判断:**
|
||||
|
||||
| 情景 | 概率 | 条件 | FG2609 走势 |
|
||||
|------|:---:|------|------------|
|
||||
| 基准 | 65% | 空头回补延续,成交量维持 | 震荡偏强,小幅上行 |
|
||||
| 回调 | 25% | 反弹到位,空头停止回补 | 冲高回落,转震荡 |
|
||||
| 加速上涨 | 10% | 增量多头入场 + 空头踩踏 | 放量大涨 |
|
||||
|
||||
### FG2611(次月):跟随主连震荡,无独立行情
|
||||
|
||||
流动性太差,全周净多仅 +0.25 万。唯一关注点:**华泰期货 07-23 多单暴增 +0.74 万**——如果周一华泰继续加多,是偏多信号;如果减仓,说明只是一日游。
|
||||
|
||||
### FG2701(远月):偏空但有回补风险,不建议追空
|
||||
|
||||
- 三类资金一致偏空,总净空 -2.46 万,方向最统一。
|
||||
- **但空头已高度拥挤**:国泰一家净空 3.15 万 + 周五新空头集中入场(+1.23 万)。当一个方向所有人都已上船,反向的边际冲击最大。
|
||||
- **核心变量是 FG2609**:如果主连大幅反弹触发远月空头回补,FG2701 涨幅可能超 FG2609(空头踩踏)。国泰的 4.54 万套保盘不会动,但投机空头会跑得很快。
|
||||
|
||||
| 情景 | 概率 | 触发条件 | FG2701 走势 |
|
||||
|------|:---:|---------|------------|
|
||||
| 基准 | 60% | FG2609 小幅反弹,国泰维持空单 | 震荡偏弱,涨幅受限 |
|
||||
| 空头回补 | 25% | FG2609 大幅反弹,投机空头平仓 | 涨幅可能超 FG2609 |
|
||||
| 继续加空 | 15% | 市场情绪恶化,产业加大套保 | 独立走弱 |
|
||||
|
||||
---
|
||||
|
||||
## 五、综合结论
|
||||
|
||||
> **下个交易日整体判断:偏多震荡。FG2609 是核心战场,空头回补是主要驱动。FG2701 的空头拥挤是最大的潜在爆发点——如果主连大幅上涨触发远月空头回补,可能出现近远月同步拉升的极端行情。反之,如果主连反弹乏力、成交量萎缩,则空头回补行情接近尾声,谨慎追多。**
|
||||
|
||||
### 周一盘中关键观察指标
|
||||
|
||||
1. **FG2609 成交量**:放量 = 空头继续砍仓(偏多),缩量 = 回补动能衰竭(转震荡)
|
||||
2. **中信/高盛/国泰空单变化**(盘后数据):继续下降则反弹延续
|
||||
3. **FG2701 是否跟涨**:跟涨说明空头回补扩散;不跟涨是正常期限结构
|
||||
4. **华泰 FG2611 多单**:继续增加则强化远月翻多信号
|
||||
5. **冠通期货持仓方向**:如果其方向为多,则强化偏多;如果为空,则是新的压力来源
|
||||
+265
-5
@@ -1,14 +1,159 @@
|
||||
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
socket.setdefaulttimeout(15)
|
||||
from app.database import SessionLocal
|
||||
from app.models import DailyBar, Contract
|
||||
from app.engine.lock_strategy import compute_amp_5d
|
||||
|
||||
_progress = {"running": False, "label": "", "done": 0, "total": 0, "result": 0, "finished": False, "error": "", "started_at": 0}
|
||||
_lock = threading.Lock()
|
||||
|
||||
STALE_SECONDS = 120
|
||||
|
||||
|
||||
def get_progress() -> dict:
|
||||
with _lock:
|
||||
return dict(_progress)
|
||||
|
||||
|
||||
def _start_bg(target, label):
|
||||
with _lock:
|
||||
now = time.time()
|
||||
if _progress["running"] and (now - _progress["started_at"]) < STALE_SECONDS:
|
||||
return False
|
||||
_progress.update(running=True, finished=False, label=label, done=0, total=0, error="", started_at=now)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
target()
|
||||
except Exception as e:
|
||||
with _lock:
|
||||
_progress["error"] = str(e)
|
||||
finally:
|
||||
with _lock:
|
||||
_progress["running"] = False
|
||||
_progress["finished"] = True
|
||||
_progress["done"] = _progress["total"]
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def sync_contract_bars_bg(contract_code: str) -> bool:
|
||||
"""Sync bars for one contract in background. Returns True if started."""
|
||||
code = contract_code.upper()
|
||||
|
||||
def _run():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
latest = (
|
||||
db.query(DailyBar.date)
|
||||
.filter(DailyBar.contract == code)
|
||||
.order_by(DailyBar.date.desc())
|
||||
.first()
|
||||
)
|
||||
start_date = latest[0].isoformat() if latest else None
|
||||
bars = fetch_contract_bars(code, start_date)
|
||||
|
||||
with _lock:
|
||||
_progress["total"] = len(bars)
|
||||
|
||||
total_bars = len(bars)
|
||||
inserted = 0
|
||||
min_date = None
|
||||
for i, bar in enumerate(bars):
|
||||
existing = (
|
||||
db.query(DailyBar)
|
||||
.filter(DailyBar.contract == code, DailyBar.date == bar["date"])
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
db.add(DailyBar(
|
||||
contract=code, date=bar["date"],
|
||||
open=bar["open"], close=bar["close"],
|
||||
high=bar["high"], low=bar["low"],
|
||||
))
|
||||
inserted += 1
|
||||
if min_date is None or bar["date"] < min_date:
|
||||
min_date = bar["date"]
|
||||
with _lock:
|
||||
_progress["done"] = i + 1
|
||||
_progress["label"] = f"同步行情 {code} · {i + 1}/{total_bars} 条"
|
||||
|
||||
if inserted > 0:
|
||||
db.flush()
|
||||
_recompute_amp(db, code, from_date=min_date)
|
||||
db.commit()
|
||||
with _lock:
|
||||
_progress["result"] = inserted
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return _start_bg(_run, f"同步行情 {code}")
|
||||
|
||||
|
||||
def sync_positions_bg(contract_code: str) -> bool:
|
||||
"""Sync position rankings for one contract in background. Returns True if started."""
|
||||
from app.models import PositionRanking
|
||||
code = contract_code.upper()
|
||||
|
||||
def _run():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing_dates = {
|
||||
r[0] for r in
|
||||
db.query(PositionRanking.date)
|
||||
.filter(PositionRanking.contract_code == code)
|
||||
.distinct().all()
|
||||
}
|
||||
bar_dates = [
|
||||
r[0] for r in
|
||||
db.query(DailyBar.date)
|
||||
.filter(DailyBar.contract == code)
|
||||
.order_by(DailyBar.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
]
|
||||
missing = [d for d in bar_dates if d not in existing_dates]
|
||||
total_missing = len(missing)
|
||||
|
||||
with _lock:
|
||||
_progress["total"] = total_missing
|
||||
if total_missing == 0:
|
||||
_progress["label"] = f"同步持仓 {code} · 已是最新"
|
||||
|
||||
inserted = 0
|
||||
for i, d in enumerate(missing):
|
||||
rankings = fetch_position_rankings(code, d.strftime("%Y%m%d"))
|
||||
for r in rankings:
|
||||
db.add(PositionRanking(
|
||||
contract_code=code, institution=r["institution"],
|
||||
data_type=r["data_type"], date=d,
|
||||
rank=r["rank"], value=r["value"], change=r["change"],
|
||||
))
|
||||
inserted += 1
|
||||
db.flush()
|
||||
with _lock:
|
||||
_progress["done"] = i + 1
|
||||
_progress["label"] = f"同步持仓 {code} · {i + 1}/{total_missing} 天"
|
||||
db.commit()
|
||||
with _lock:
|
||||
_progress["result"] = inserted
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return _start_bg(_run, f"同步持仓 {code}")
|
||||
|
||||
|
||||
def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]:
|
||||
"""Fetch daily OHLCV for a single contract from akshare.
|
||||
|
||||
Returns list of {date, open, close, high, low} dicts.
|
||||
Only returns bars on or after start_date when provided.
|
||||
"""
|
||||
import akshare as ak
|
||||
|
||||
@@ -22,6 +167,8 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
|
||||
print(f"[collector] No data returned for {contract_code}")
|
||||
return []
|
||||
|
||||
filter_date = date.fromisoformat(start_date) if start_date else None
|
||||
|
||||
bars = []
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
@@ -31,7 +178,7 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
|
||||
else:
|
||||
d = date.fromisoformat(str(val)[:10])
|
||||
|
||||
if start_date and d <= date.fromisoformat(start_date):
|
||||
if filter_date and d < filter_date:
|
||||
continue
|
||||
|
||||
bars.append({
|
||||
@@ -95,6 +242,7 @@ def _sync_one(db, contract_code: str) -> int:
|
||||
return 0
|
||||
|
||||
inserted = 0
|
||||
min_date = None
|
||||
for bar in bars:
|
||||
existing = (
|
||||
db.query(DailyBar)
|
||||
@@ -114,22 +262,134 @@ def _sync_one(db, contract_code: str) -> int:
|
||||
low=bar["low"],
|
||||
))
|
||||
inserted += 1
|
||||
if min_date is None or bar["date"] < min_date:
|
||||
min_date = bar["date"]
|
||||
|
||||
if inserted > 0:
|
||||
db.flush()
|
||||
_recompute_amp(db, contract_code)
|
||||
_recompute_amp(db, contract_code, from_date=min_date)
|
||||
|
||||
return inserted
|
||||
|
||||
|
||||
def _recompute_amp(db, contract_code: str):
|
||||
"""Recompute amp_5d for all bars of a contract."""
|
||||
def fetch_position_rankings(contract_code: str, trade_date: str) -> list[dict]:
|
||||
"""Fetch top-20 position rankings for a contract on a given date from akshare.
|
||||
|
||||
Calls the API 3 times (volume, long, short) and returns a unified list of dicts:
|
||||
{data_type, rank, institution, value, change}
|
||||
"""
|
||||
import akshare as ak
|
||||
|
||||
results = []
|
||||
for sym, dtype in [("成交量", "volume"), ("多单持仓", "long"), ("空单持仓", "short")]:
|
||||
try:
|
||||
df = ak.futures_hold_pos_sina(symbol=sym, contract=contract_code.upper(), date=trade_date)
|
||||
except Exception as e:
|
||||
print(f"[collector] position akshare error for {contract_code} {dtype}: {e}")
|
||||
continue
|
||||
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
inst = str(row["会员简称"]).strip()
|
||||
rank = int(row["名次"])
|
||||
if inst in ("-", ""):
|
||||
inst = f"-{rank}"
|
||||
results.append({
|
||||
"data_type": dtype,
|
||||
"rank": rank,
|
||||
"institution": inst,
|
||||
"value": int(row.iloc[2]),
|
||||
"change": int(row["比上交易增减"]),
|
||||
})
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
print(f"[collector] position skip row: {e}")
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def sync_position_rankings() -> dict:
|
||||
"""Sync position rankings for all active contracts. Returns {contract_code: new_rows}."""
|
||||
db = SessionLocal()
|
||||
results = {}
|
||||
|
||||
try:
|
||||
active_contracts = (
|
||||
db.query(Contract).filter(Contract.is_active == True).all()
|
||||
)
|
||||
|
||||
for c in active_contracts:
|
||||
count = _sync_positions_for_contract(db, c.code)
|
||||
results[c.code] = count
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _sync_positions_for_contract(db, contract_code: str) -> int:
|
||||
"""Sync position rankings for all dates that have bars but no position data."""
|
||||
from app.models import PositionRanking
|
||||
|
||||
code = contract_code.upper()
|
||||
|
||||
existing_dates = {
|
||||
r[0] for r in
|
||||
db.query(PositionRanking.date)
|
||||
.filter(PositionRanking.contract_code == code)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
|
||||
bar_dates = [
|
||||
r[0] for r in
|
||||
db.query(DailyBar.date)
|
||||
.filter(DailyBar.contract == code)
|
||||
.order_by(DailyBar.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
]
|
||||
inserted = 0
|
||||
for d in bar_dates:
|
||||
if d in existing_dates:
|
||||
continue
|
||||
date_str = d.strftime("%Y%m%d")
|
||||
rankings = fetch_position_rankings(code, date_str)
|
||||
for r in rankings:
|
||||
db.add(PositionRanking(
|
||||
contract_code=code,
|
||||
institution=r["institution"],
|
||||
data_type=r["data_type"],
|
||||
date=d,
|
||||
rank=r["rank"],
|
||||
value=r["value"],
|
||||
change=r["change"],
|
||||
))
|
||||
inserted += 1
|
||||
db.flush()
|
||||
|
||||
return inserted
|
||||
|
||||
|
||||
def _recompute_amp(db, contract_code: str, from_date: date | None = None):
|
||||
"""Recompute amp_5d for bars of a contract from from_date onwards."""
|
||||
bars = (
|
||||
db.query(DailyBar)
|
||||
.filter(DailyBar.contract == contract_code)
|
||||
.order_by(DailyBar.date)
|
||||
.all()
|
||||
)
|
||||
start_idx = 0
|
||||
if from_date:
|
||||
for i, bar in enumerate(bars):
|
||||
if i >= 5:
|
||||
if bar.date >= from_date:
|
||||
start_idx = i
|
||||
break
|
||||
for i, bar in enumerate(bars):
|
||||
if i >= 5 and i >= start_idx:
|
||||
bar.amp_5d = compute_amp_5d([b.diff for b in bars[i - 5 : i]])
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""博弈分析计算引擎"""
|
||||
|
||||
|
||||
def net_position(long_pos: int, short_pos: int) -> int:
|
||||
"""净持仓 = 多单 - 空单。正=净多, 负=净空"""
|
||||
return long_pos - short_pos
|
||||
|
||||
|
||||
def net_pnl(net_pos: int, avg_cost: float, current_price: float) -> float:
|
||||
"""净盈亏 = 净持仓 × (现价 - 成本均价) × 20"""
|
||||
return net_pos * (current_price - avg_cost) * 20
|
||||
|
||||
|
||||
def cost_delta(old_cost: float, new_cost: float) -> float:
|
||||
"""成本变化 = 新均价 - 旧均价"""
|
||||
return round(new_cost - old_cost, 2)
|
||||
|
||||
|
||||
def format_pnl(pnl_yuan: float) -> str:
|
||||
"""格式化盈亏为亿/万"""
|
||||
yi = abs(pnl_yuan) / 1e8
|
||||
if yi >= 0.01:
|
||||
sign = "+" if pnl_yuan >= 0 else "-"
|
||||
return f"{sign}{yi:.2f}亿"
|
||||
wan = abs(pnl_yuan) / 1e4
|
||||
return f"{'+' if pnl_yuan >= 0 else '-'}{wan:.1f}万"
|
||||
@@ -28,6 +28,6 @@ def check_lock(
|
||||
return (current_price - open_price) >= lock_threshold
|
||||
|
||||
|
||||
def should_meltdown(lock_count: int) -> bool:
|
||||
"""3锁熔断"""
|
||||
return lock_count >= 3
|
||||
def should_meltdown(lock_count: int, max_locks: int = 3) -> bool:
|
||||
"""锁仓次数达到上限时熔断"""
|
||||
return lock_count >= max_locks
|
||||
|
||||
+36
-6
@@ -1,10 +1,13 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.database import engine, Base
|
||||
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, analysis, data_input, admin
|
||||
from app.routers import contracts, admin, auth, positions, trades, option_trades
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
@@ -14,6 +17,30 @@ def setup_jinja(app: FastAPI):
|
||||
app.state.templates = env
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Public paths
|
||||
if request.url.path in ("/auth/login", "/auth/logout") or request.url.path.startswith("/auth/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Check session
|
||||
user_id = request.cookies.get("ft_session")
|
||||
if user_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||
if user:
|
||||
request.state.user = user
|
||||
return await call_next(request)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Redirect to login
|
||||
return RedirectResponse(f"/auth/login?next={request.url.path}", status_code=303)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
setup_jinja(app)
|
||||
@@ -24,13 +51,16 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="期货量化系统", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(contracts.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(data_input.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(positions.router)
|
||||
app.include_router(trades.router)
|
||||
app.include_router(option_trades.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/contracts")
|
||||
|
||||
+125
-8
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import date
|
||||
from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -49,14 +51,129 @@ class DailyBar(Base):
|
||||
return self.high - self.low
|
||||
|
||||
|
||||
class PositionSnapshot(Base):
|
||||
__tablename__ = "position_snapshots"
|
||||
__table_args__ = (UniqueConstraint("institution", "direction", "date"),)
|
||||
class PositionRanking(Base):
|
||||
__tablename__ = "position_rankings"
|
||||
__table_args__ = (UniqueConstraint("contract_code", "institution", "data_type", "date"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
institution: Mapped[str] = mapped_column(String(20), index=True)
|
||||
direction: Mapped[str] = mapped_column(String(10)) # "long" or "short"
|
||||
contract_code: Mapped[str] = mapped_column(String(10), index=True)
|
||||
institution: Mapped[str] = mapped_column(String(30), index=True)
|
||||
data_type: Mapped[str] = mapped_column(String(10), index=True)
|
||||
date: Mapped[date] = mapped_column(Date, index=True)
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
delta: Mapped[int] = mapped_column(Integer, default=0)
|
||||
avg_cost: Mapped[float] = mapped_column(Float)
|
||||
rank: Mapped[int] = mapped_column(Integer)
|
||||
value: Mapped[int] = mapped_column(Integer)
|
||||
change: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(128))
|
||||
role: Mapped[str] = mapped_column(String(20), default="user")
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
|
||||
return salt.hex() + ":" + dk.hex()
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
try:
|
||||
salt_hex, dk_hex = self.password_hash.split(":")
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
|
||||
return dk.hex() == dk_hex
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
class Round(Base):
|
||||
__tablename__ = "rounds"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
contract_code: Mapped[str] = mapped_column(String(10), index=True)
|
||||
daily_hands: Mapped[int] = mapped_column(Integer, default=1)
|
||||
max_locks: Mapped[int] = mapped_column(Integer, default=3)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
started_at: Mapped[date] = mapped_column(Date)
|
||||
|
||||
positions: Mapped[list["Position"]] = relationship(
|
||||
back_populates="round", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def total_locks(self) -> int:
|
||||
return sum(p.locked_count for p in self.positions)
|
||||
|
||||
|
||||
class Position(Base):
|
||||
__tablename__ = "positions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
|
||||
open_price: Mapped[int] = mapped_column(Integer)
|
||||
amp_threshold: Mapped[int] = mapped_column(Integer)
|
||||
lock_price: Mapped[int] = mapped_column(Integer)
|
||||
hands: Mapped[int] = mapped_column(Integer, default=1)
|
||||
locked_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
opened_at: Mapped[date] = mapped_column(Date)
|
||||
|
||||
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[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
|
||||
if self.direction == "long":
|
||||
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))
|
||||
direction: 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
|
||||
if self.direction == "sell":
|
||||
result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
|
||||
else:
|
||||
result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
|
||||
return round(result, 2)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""持仓分析 prompt 生成器。
|
||||
|
||||
根据最近 5 个交易日的主力持仓排名生成结构化分析 prompt,
|
||||
按席位属性(家人/大户/其他/噪音)分类汇总,聚焦资金风向变化。
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import PositionRanking, Contract
|
||||
|
||||
|
||||
# ── 席位分类 ──
|
||||
FAMILY_NAMES = [
|
||||
"东方财富", "徽商期货", "方正中期", "中信建投", "平安期货",
|
||||
]
|
||||
WHALE_NAMES = [
|
||||
"永安期货", "中信期货", "国泰君安", "海通期货", "华泰期货",
|
||||
"高盛期货", "乾坤期货", "摩根大通", "中粮期货",
|
||||
]
|
||||
NOISE_NAMES = ["东证期货"]
|
||||
|
||||
TYPE_LABEL = {"volume": "成交量", "long": "多单持仓", "short": "空单持仓"}
|
||||
|
||||
|
||||
def _classify(inst: str) -> str:
|
||||
"""匹配席位分类,返回 家人/大户/噪音/其他。"""
|
||||
for kw in FAMILY_NAMES:
|
||||
if kw in inst:
|
||||
return "家人"
|
||||
for kw in WHALE_NAMES:
|
||||
if kw in inst:
|
||||
return "大户"
|
||||
for kw in NOISE_NAMES:
|
||||
if kw in inst:
|
||||
return "噪音"
|
||||
return "其他"
|
||||
|
||||
|
||||
def _build_legend() -> str:
|
||||
"""生成席位分类图例。"""
|
||||
lines = ["## 席位分类", ""]
|
||||
lines.append(f"- **家人席位(散户)**: {'、'.join(FAMILY_NAMES)}")
|
||||
lines.append(f"- **大户席位(产业/套保)**: {'、'.join(WHALE_NAMES)}")
|
||||
lines.append(f"- **噪音席位(忽略)**: {'、'.join(NOISE_NAMES)}")
|
||||
lines.append("- **其他席位**: 未归入以上三类的席位")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_number(n: int) -> str:
|
||||
"""数字格式化为万手。"""
|
||||
v = n / 10000
|
||||
sign = "+" if n > 0 else ""
|
||||
return f"{sign}{v:.2f}万"
|
||||
|
||||
|
||||
def build_prompt(contract_code: str | None = None) -> str:
|
||||
"""生成单个或全部活跃合约的持仓分析 prompt。
|
||||
|
||||
Args:
|
||||
contract_code: 合约代码(如 FG2609),为 None 时分析所有活跃合约。
|
||||
|
||||
Returns:
|
||||
可直接发送给 AI 进行分析的 prompt 文本。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if contract_code:
|
||||
codes = [contract_code.upper()]
|
||||
else:
|
||||
codes = [
|
||||
r[0] for r in
|
||||
db.query(Contract.code)
|
||||
.filter(Contract.is_active == True)
|
||||
.order_by(Contract.code)
|
||||
.all()
|
||||
]
|
||||
|
||||
# 取最近 5 个有持仓数据的交易日
|
||||
all_dates = sorted({
|
||||
r[0] for r in
|
||||
db.query(PositionRanking.date)
|
||||
.filter(PositionRanking.contract_code.in_(codes))
|
||||
.distinct()
|
||||
.all()
|
||||
}, reverse=True)[:5]
|
||||
all_dates.reverse() # 从旧到新
|
||||
|
||||
if not all_dates:
|
||||
return "无持仓数据"
|
||||
|
||||
# 拉取全部数据
|
||||
rankings = (
|
||||
db.query(PositionRanking)
|
||||
.filter(
|
||||
PositionRanking.contract_code.in_(codes),
|
||||
PositionRanking.date.in_(all_dates),
|
||||
)
|
||||
.order_by(PositionRanking.contract_code, PositionRanking.date, PositionRanking.data_type, PositionRanking.rank)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 按 contract → date → data_type 组织
|
||||
data: dict[str, dict] = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
|
||||
for r in rankings:
|
||||
data[r.contract_code][r.date][r.data_type].append(r)
|
||||
|
||||
parts = [_build_legend()]
|
||||
|
||||
for code in codes:
|
||||
if code not in data:
|
||||
continue
|
||||
cd = data[code]
|
||||
parts.append(f"## {code}")
|
||||
parts.append("")
|
||||
parts.append(f"分析区间: {min(all_dates)} → {max(all_dates)}(共 {len(all_dates)} 个交易日)")
|
||||
parts.append("")
|
||||
|
||||
# ── 每日明细 ──
|
||||
for d in all_dates:
|
||||
if d not in cd:
|
||||
continue
|
||||
parts.append(f"### {d} {_weekday_zh(d)}")
|
||||
parts.append("")
|
||||
|
||||
for dtype in ["volume", "long", "short"]:
|
||||
rows = cd[d].get(dtype, [])
|
||||
if not rows:
|
||||
continue
|
||||
parts.append(f"**{TYPE_LABEL[dtype]} Top 20**")
|
||||
parts.append("")
|
||||
parts.append("| 排名 | 席位 | 分类 | 持仓量 | 增减 |")
|
||||
parts.append("|------|------|------|--------|------|")
|
||||
for r in rows[:20]:
|
||||
tag = _classify(r.institution)
|
||||
if tag == "噪音":
|
||||
continue # 跳过东证
|
||||
parts.append(
|
||||
f"| {r.rank} | {r.institution} | {tag} | "
|
||||
f"{_format_number(r.value)} | {_format_number(r.change)} |"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
# ── 每日家人 vs 大户汇总 ──
|
||||
parts.append(f"### {code} 分类资金变化汇总")
|
||||
parts.append("")
|
||||
parts.append("| 日期 | 家人多单增减 | 家人空单增减 | 大户多单增减 | 大户空单增减 | 其他多单增减 | 其他空单增减 |")
|
||||
parts.append("|------|-------------|-------------|-------------|-------------|-------------|-------------|")
|
||||
|
||||
for d in all_dates:
|
||||
if d not in cd:
|
||||
continue
|
||||
cat_changes = defaultdict(lambda: defaultdict(int))
|
||||
for dtype in ["long", "short"]:
|
||||
for r in cd[d].get(dtype, []):
|
||||
tag = _classify(r.institution)
|
||||
if tag == "噪音":
|
||||
continue
|
||||
cat_changes[tag][dtype] += r.change
|
||||
|
||||
parts.append(
|
||||
f"| {d} "
|
||||
f"| {_format_number(cat_changes['家人']['long'])} "
|
||||
f"| {_format_number(cat_changes['家人']['short'])} "
|
||||
f"| {_format_number(cat_changes['大户']['long'])} "
|
||||
f"| {_format_number(cat_changes['大户']['short'])} "
|
||||
f"| {_format_number(cat_changes['其他']['long'])} "
|
||||
f"| {_format_number(cat_changes['其他']['short'])} |"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
# ── 5日累计 ──
|
||||
parts.append(f"### {code} 5日累计资金变化")
|
||||
parts.append("")
|
||||
total_cat = defaultdict(lambda: defaultdict(int))
|
||||
for d in all_dates:
|
||||
if d not in cd:
|
||||
continue
|
||||
for dtype in ["long", "short"]:
|
||||
for r in cd[d].get(dtype, []):
|
||||
tag = _classify(r.institution)
|
||||
if tag == "噪音":
|
||||
continue
|
||||
total_cat[tag][dtype] += r.change
|
||||
|
||||
parts.append("| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空) |")
|
||||
parts.append("|------|------------|------------|--------------|")
|
||||
for tag in ["家人", "大户", "其他"]:
|
||||
net = total_cat[tag]["long"] - total_cat[tag]["short"]
|
||||
net_str = _format_number(net)
|
||||
parts.append(
|
||||
f"| {tag} | {_format_number(total_cat[tag]['long'])} "
|
||||
f"| {_format_number(total_cat[tag]['short'])} | {net_str} |"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
# ── 分析引导 ──
|
||||
parts.append("---")
|
||||
parts.append("")
|
||||
parts.append("请基于以上数据进行分析,重点关注:")
|
||||
parts.append("")
|
||||
parts.append("1. **家人席位动向**: 5日内多空增减趋势,散户情绪偏多还是偏空,"
|
||||
"是否有明显的追涨杀跌行为。")
|
||||
parts.append("2. **大户席位动向**: 产业资金和套保资金的布局方向,"
|
||||
"多空增减是否与家人席位形成对手盘。")
|
||||
parts.append("3. **多空力量对比**: 各分类的净多/净空方向,"
|
||||
"整体市场情绪偏向。")
|
||||
parts.append("4. **异常信号**: 单日大幅增减的席位,"
|
||||
"分类资金出现明显分歧的交易日。")
|
||||
parts.append("5. **行情预判**: 结合席位资金流向,"
|
||||
"对下一交易日走势给出偏多/偏空/震荡的判断及理由。")
|
||||
|
||||
return "\n".join(parts)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _weekday_zh(d: date) -> str:
|
||||
return ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][d.weekday()]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.path.insert(0, "/app")
|
||||
from app.prompt_builder import build_prompt
|
||||
prompt = build_prompt()
|
||||
print(prompt)
|
||||
+77
-25
@@ -1,9 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import Product, Contract
|
||||
from app.collector import sync_active_contracts, sync_one_contract
|
||||
from app.models import Product, Contract, DailyBar, PositionRanking
|
||||
from app.collector import (
|
||||
sync_one_contract, sync_contract_bars_bg, sync_positions_bg, get_progress,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -22,23 +24,42 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
|
||||
.order_by(Contract.code)
|
||||
.all()
|
||||
)
|
||||
contract_list = []
|
||||
for c in contracts:
|
||||
bar_count = (
|
||||
db.query(DailyBar)
|
||||
.filter(DailyBar.contract == c.code)
|
||||
.count()
|
||||
)
|
||||
pos_days = len({
|
||||
r[0] for r in
|
||||
db.query(PositionRanking.date)
|
||||
.filter(PositionRanking.contract_code == c.code)
|
||||
.distinct().all()
|
||||
})
|
||||
contract_list.append({
|
||||
"id": c.id, "code": c.code, "name": c.name,
|
||||
"is_active": c.is_active, "bar_count": bar_count,
|
||||
"pos_days": pos_days,
|
||||
})
|
||||
|
||||
product_data.append({
|
||||
"id": p.id,
|
||||
"code": p.code,
|
||||
"name": p.name,
|
||||
"exchange": p.exchange,
|
||||
"contracts": [
|
||||
{"id": c.id, "code": c.code, "name": c.name, "is_active": c.is_active}
|
||||
for c in contracts
|
||||
],
|
||||
"contracts": contract_list,
|
||||
})
|
||||
|
||||
total_contracts = sum(len(pd["contracts"]) for pd in product_data)
|
||||
|
||||
template = request.app.state.templates.get_template("admin.html")
|
||||
return HTMLResponse(
|
||||
template.render(
|
||||
request=request,
|
||||
active_nav="admin",
|
||||
products=product_data,
|
||||
total_contracts=total_contracts,
|
||||
exchanges=EXCHANGES,
|
||||
)
|
||||
)
|
||||
@@ -57,28 +78,34 @@ def create_product(
|
||||
p = Product(code=code.upper(), name=name, exchange=exchange)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/", status_code=303)
|
||||
return RedirectResponse("/admin/?tab=product", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contract")
|
||||
def create_contract(
|
||||
request: Request,
|
||||
product_id: int = Form(...),
|
||||
code: str = Form(...),
|
||||
name: str = Form(...),
|
||||
month: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
existing = db.query(Contract).filter(Contract.code == code.upper()).first()
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
return RedirectResponse("/admin/?tab=contract", status_code=303)
|
||||
|
||||
code = (product.code + month).upper()
|
||||
name = month.upper()
|
||||
|
||||
existing = db.query(Contract).filter(Contract.code == code).first()
|
||||
if not existing:
|
||||
c = Contract(
|
||||
product_id=product_id,
|
||||
code=code.upper(),
|
||||
code=code,
|
||||
name=name,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/", status_code=303)
|
||||
return RedirectResponse("/admin/?tab=contract", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contract/{contract_id}/toggle")
|
||||
@@ -87,36 +114,61 @@ def toggle_contract(contract_id: int, db: Session = Depends(get_db)):
|
||||
if c:
|
||||
c.is_active = not c.is_active
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/", status_code=303)
|
||||
return RedirectResponse("/admin/?tab=contract", status_code=303)
|
||||
|
||||
|
||||
@router.post("/contract/{contract_id}/delete")
|
||||
def delete_contract(contract_id: int, db: Session = Depends(get_db)):
|
||||
c = db.query(Contract).filter(Contract.id == contract_id).first()
|
||||
if c:
|
||||
db.query(DailyBar).filter(DailyBar.contract == c.code).delete()
|
||||
db.query(PositionRanking).filter(PositionRanking.contract_code == c.code).delete()
|
||||
db.delete(c)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/", status_code=303)
|
||||
return RedirectResponse("/admin/?tab=contract", status_code=303)
|
||||
|
||||
|
||||
@router.post("/product/{product_id}/delete")
|
||||
def delete_product(product_id: int, db: Session = Depends(get_db)):
|
||||
p = db.query(Product).filter(Product.id == product_id).first()
|
||||
if p:
|
||||
for c in p.contracts:
|
||||
db.query(DailyBar).filter(DailyBar.contract == c.code).delete()
|
||||
db.query(PositionRanking).filter(PositionRanking.contract_code == c.code).delete()
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
def sync_all(request: Request):
|
||||
results = sync_active_contracts()
|
||||
total = sum(results.values())
|
||||
print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}")
|
||||
return RedirectResponse("/admin/?synced=" + str(total), status_code=303)
|
||||
return RedirectResponse("/admin/?tab=product", status_code=303)
|
||||
|
||||
|
||||
@router.post("/sync/{contract_code}")
|
||||
def sync_single(contract_code: str):
|
||||
count = sync_one_contract(contract_code.upper())
|
||||
return RedirectResponse(f"/admin/?synced={count}", status_code=303)
|
||||
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/sync/{contract_code}/bg")
|
||||
def sync_single_bg(contract_code: str):
|
||||
ok = sync_contract_bars_bg(contract_code.upper())
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@router.post("/sync-positions/{contract_code}/bg")
|
||||
def sync_positions_bg_ep(contract_code: str):
|
||||
ok = sync_positions_bg(contract_code.upper())
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@router.get("/sync-status")
|
||||
def sync_status():
|
||||
return JSONResponse(get_progress())
|
||||
|
||||
|
||||
@router.post("/sync/product/{product_id}")
|
||||
def sync_product(product_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
contracts = db.query(Contract).filter(
|
||||
Contract.product_id == product_id, Contract.is_active == True
|
||||
).all()
|
||||
total = 0
|
||||
for c in contracts:
|
||||
total += sync_one_contract(c.code)
|
||||
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import PositionSnapshot
|
||||
from app.engine.game_theory import net_position, net_pnl, format_pnl
|
||||
|
||||
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
||||
|
||||
INSTITUTIONS = ["中信期货", "高盛期货", "国泰君安期货", "华泰期货", "东证期货", "银河期货"]
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def analysis_page(request: Request, db: Session = Depends(get_db)):
|
||||
latest_snap = (
|
||||
db.query(PositionSnapshot.date)
|
||||
.order_by(PositionSnapshot.date.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not latest_snap:
|
||||
template = request.app.state.templates.get_template("analysis.html")
|
||||
return HTMLResponse(
|
||||
template.render(request=request, active_nav="analysis", rows=[], latest_date=None)
|
||||
)
|
||||
|
||||
latest_date = latest_snap[0]
|
||||
current_price = 913 # TODO: fetch from daily_bars or akshare
|
||||
|
||||
rows = []
|
||||
totals = {"long_pos": 0, "short_pos": 0, "net_pos": 0, "pnl": 0.0}
|
||||
total_net_short = 0
|
||||
pnl_sum = 0.0
|
||||
|
||||
for inst in INSTITUTIONS:
|
||||
long = (
|
||||
db.query(PositionSnapshot)
|
||||
.filter(
|
||||
PositionSnapshot.institution == inst,
|
||||
PositionSnapshot.direction == "long",
|
||||
PositionSnapshot.date == latest_date,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
short = (
|
||||
db.query(PositionSnapshot)
|
||||
.filter(
|
||||
PositionSnapshot.institution == inst,
|
||||
PositionSnapshot.direction == "short",
|
||||
PositionSnapshot.date == latest_date,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
long_pos = long.position if long else 0
|
||||
short_pos = short.position if short else 0
|
||||
long_cost = long.avg_cost if long else 0
|
||||
short_cost = short.avg_cost if short else 0
|
||||
|
||||
np = net_position(long_pos, short_pos)
|
||||
pnl = 0.0
|
||||
if np < 0:
|
||||
pnl = net_pnl(np, short_cost, current_price)
|
||||
elif np > 0:
|
||||
pnl = net_pnl(np, long_cost, current_price)
|
||||
|
||||
totals["long_pos"] += long_pos
|
||||
totals["short_pos"] += short_pos
|
||||
totals["net_pos"] += np
|
||||
pnl_sum += pnl
|
||||
if np < 0:
|
||||
total_net_short += abs(np)
|
||||
|
||||
rows.append({
|
||||
"institution": inst,
|
||||
"long_pos": f"{long_pos / 10000:.1f}万" if long_pos else "—",
|
||||
"short_pos": f"{short_pos / 10000:.1f}万" if short_pos else "—",
|
||||
"long_cost": f"{long_cost:.2f}" if long_cost else "—",
|
||||
"short_cost": f"{short_cost:.2f}" if short_cost else "—",
|
||||
"net_pos": f"净{'多' if np > 0 else '空'} {abs(np) / 10000:.1f}万",
|
||||
"pnl": format_pnl(pnl),
|
||||
"pnl_raw": pnl,
|
||||
})
|
||||
|
||||
template = request.app.state.templates.get_template("analysis.html")
|
||||
return HTMLResponse(
|
||||
template.render(
|
||||
request=request,
|
||||
active_nav="analysis",
|
||||
rows=rows,
|
||||
totals={
|
||||
"long_pos": f"{totals['long_pos'] / 10000:.1f}万",
|
||||
"short_pos": f"{totals['short_pos'] / 10000:.1f}万",
|
||||
"net_pos": f"净{'多' if totals['net_pos'] > 0 else '空'} {abs(totals['net_pos']) / 10000:.1f}万",
|
||||
"pnl": format_pnl(pnl_sum),
|
||||
},
|
||||
latest_date=latest_date.strftime("%Y-%m-%d"),
|
||||
current_price=current_price,
|
||||
total_net_short=f"{total_net_short / 10000:.1f}万",
|
||||
total_pnl=format_pnl(pnl_sum),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
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 User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
SESSION_COOKIE = "ft_session"
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
template = request.app.state.templates.get_template("login.html")
|
||||
return HTMLResponse(template.render(request=request))
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user or not user.check_password(password):
|
||||
template = request.app.state.templates.get_template("login.html")
|
||||
return HTMLResponse(
|
||||
template.render(request=request, error="用户名或密码错误"),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
resp = RedirectResponse("/contracts/", status_code=303)
|
||||
resp.set_cookie(SESSION_COOKIE, str(user.id), httponly=True)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout():
|
||||
resp = RedirectResponse("/auth/login", status_code=303)
|
||||
resp.delete_cookie(SESSION_COOKIE)
|
||||
return resp
|
||||
+138
-17
@@ -1,13 +1,16 @@
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
from fastapi import APIRouter, Depends, Request, Query
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import DailyBar, Contract
|
||||
from app.models import DailyBar, Contract, PositionRanking
|
||||
from app.prompt_builder import build_prompt
|
||||
|
||||
router = APIRouter(prefix="/contracts", tags=["contracts"])
|
||||
|
||||
WEEKDAY_ZH = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"}
|
||||
PAGE_SIZE = 7
|
||||
|
||||
|
||||
def get_active_contracts(db: Session) -> list[str]:
|
||||
@@ -22,11 +25,33 @@ def get_active_contracts(db: Session) -> list[str]:
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def contract_index(request: Request, db: Session = Depends(get_db)):
|
||||
active_contracts = get_active_contracts(db)
|
||||
from app.models import Product
|
||||
|
||||
# Build product → contracts mapping, ordered by product code
|
||||
products = (
|
||||
db.query(Product)
|
||||
.order_by(Product.code)
|
||||
.all()
|
||||
)
|
||||
|
||||
product_groups = []
|
||||
all_codes = []
|
||||
for prod in products:
|
||||
codes = [
|
||||
c.code for c in prod.contracts if c.is_active
|
||||
]
|
||||
if codes:
|
||||
product_groups.append({
|
||||
"code": prod.code,
|
||||
"name": prod.name,
|
||||
"exchange": prod.exchange,
|
||||
"contracts": codes,
|
||||
})
|
||||
all_codes.extend(codes)
|
||||
|
||||
latest = (
|
||||
db.query(DailyBar)
|
||||
.filter(DailyBar.contract.in_(active_contracts))
|
||||
.filter(DailyBar.contract.in_(all_codes))
|
||||
.order_by(DailyBar.date.desc())
|
||||
.all()
|
||||
)
|
||||
@@ -35,37 +60,59 @@ def contract_index(request: Request, db: Session = Depends(get_db)):
|
||||
if bar.contract not in contract_bars:
|
||||
contract_bars[bar.contract] = bar
|
||||
|
||||
total_bars = db.query(DailyBar).count()
|
||||
latest_bar = (
|
||||
db.query(DailyBar).order_by(DailyBar.date.desc()).first()
|
||||
)
|
||||
# Next-day amplitude: mean of latest 5 diffs per contract
|
||||
contract_next_amp = {}
|
||||
for code in all_codes:
|
||||
diffs = [
|
||||
r[0] for r in
|
||||
db.query(DailyBar.high - DailyBar.low)
|
||||
.filter(DailyBar.contract == code)
|
||||
.order_by(DailyBar.date.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
]
|
||||
if len(diffs) >= 5:
|
||||
contract_next_amp[code] = round(sum(diffs) / 5)
|
||||
|
||||
template = request.app.state.templates.get_template("index.html")
|
||||
return HTMLResponse(
|
||||
template.render(
|
||||
request=request,
|
||||
active_nav="contracts",
|
||||
contracts=active_contracts,
|
||||
product_groups=product_groups,
|
||||
contract_bars=contract_bars,
|
||||
total_bars=total_bars,
|
||||
latest_date=latest_bar.date.strftime("%Y-%m-%d") if latest_bar else "—",
|
||||
contract_next_amp=contract_next_amp,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{contract}", response_class=HTMLResponse)
|
||||
def contract_detail(request: Request, contract: str, db: Session = Depends(get_db)):
|
||||
def contract_detail(
|
||||
request: Request,
|
||||
contract: str,
|
||||
pos_date: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
active_contracts = get_active_contracts(db)
|
||||
bars = (
|
||||
db.query(DailyBar)
|
||||
.filter(DailyBar.contract == contract.upper())
|
||||
.order_by(DailyBar.date)
|
||||
.order_by(DailyBar.date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
total = len(bars)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
page_bars = bars[start:start + PAGE_SIZE]
|
||||
|
||||
rows = []
|
||||
for bar in bars:
|
||||
for i, bar in enumerate(page_bars):
|
||||
global_idx = start + i
|
||||
rows.append({
|
||||
"global_idx": global_idx,
|
||||
"date": bar.date.strftime("%Y/%-m/%-d"),
|
||||
"weekday": WEEKDAY_ZH.get(bar.date.weekday(), ""),
|
||||
"open": int(bar.open) if bar.open else "-",
|
||||
@@ -77,8 +124,64 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
|
||||
"has_amp": bar.amp_5d is not None,
|
||||
})
|
||||
|
||||
latest = bars[-1] if bars else None
|
||||
latest = bars[0] if bars else None
|
||||
|
||||
# Dates that have position data (for date picker)
|
||||
pos_dates = [
|
||||
r[0] for r in
|
||||
db.query(PositionRanking.date)
|
||||
.filter(PositionRanking.contract_code == contract.upper())
|
||||
.distinct()
|
||||
.order_by(PositionRanking.date.desc())
|
||||
.all()
|
||||
]
|
||||
|
||||
# Determine which date to show position rankings for
|
||||
if pos_date:
|
||||
try:
|
||||
selected_date = date.fromisoformat(pos_date)
|
||||
except ValueError:
|
||||
selected_date = pos_dates[0] if pos_dates else None
|
||||
else:
|
||||
selected_date = pos_dates[0] if pos_dates else None
|
||||
|
||||
# Position rankings for the selected date
|
||||
pos_data = {"volume": [], "long": [], "short": []}
|
||||
if selected_date:
|
||||
rankings = (
|
||||
db.query(PositionRanking)
|
||||
.filter(
|
||||
PositionRanking.contract_code == contract.upper(),
|
||||
PositionRanking.date == selected_date,
|
||||
)
|
||||
.order_by(PositionRanking.data_type, PositionRanking.rank)
|
||||
.all()
|
||||
)
|
||||
for r in rankings:
|
||||
pos_data[r.data_type].append({
|
||||
"rank": r.rank,
|
||||
"institution": r.institution,
|
||||
"value": r.value,
|
||||
"change": r.change,
|
||||
})
|
||||
|
||||
pos_totals = {
|
||||
"long": sum(r.change for r in rankings if r.data_type == "long"),
|
||||
"short": sum(r.change for r in rankings if r.data_type == "short"),
|
||||
}
|
||||
else:
|
||||
pos_totals = {"long": 0, "short": 0}
|
||||
|
||||
# Predict next trading day amplitude: mean of latest 5 diffs
|
||||
# Compute next trading date
|
||||
next_date = latest.date + timedelta(days=1) if latest else None
|
||||
if next_date and next_date.weekday() >= 5:
|
||||
next_date += timedelta(days=7 - next_date.weekday())
|
||||
|
||||
# Predict next trading day amplitude
|
||||
next_amp = None
|
||||
if len(bars) >= 5:
|
||||
next_amp = round(sum(b.diff for b in bars[:5]) / 5)
|
||||
template = request.app.state.templates.get_template("contract.html")
|
||||
return HTMLResponse(
|
||||
template.render(
|
||||
@@ -88,6 +191,24 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
|
||||
contracts=active_contracts,
|
||||
rows=rows,
|
||||
latest=latest,
|
||||
pos_dates=pos_dates,
|
||||
selected_pos_date=selected_date,
|
||||
pos_data=pos_data,
|
||||
pos_totals=pos_totals,
|
||||
next_amp=next_amp,
|
||||
next_date=next_date.strftime("%Y/%-m/%-d") if next_date else None,
|
||||
next_weekday=WEEKDAY_ZH.get(next_date.weekday(), "") if next_date else "",
|
||||
row_count=len(rows),
|
||||
total_rows=total,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
pos_date=pos_date or "",
|
||||
WEEKDAY_ZH=WEEKDAY_ZH,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{contract}/analysis-prompt", response_class=PlainTextResponse)
|
||||
def contract_analysis_prompt(contract: str):
|
||||
"""返回合约的持仓分析 prompt 文本。"""
|
||||
return build_prompt(contract.upper())
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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 PositionSnapshot
|
||||
|
||||
router = APIRouter(prefix="/input", tags=["input"])
|
||||
|
||||
INSTITUTIONS = ["中信期货", "高盛期货", "国泰君安期货", "华泰期货", "东证期货", "银河期货"]
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def input_page(request: Request):
|
||||
template = request.app.state.templates.get_template("input.html")
|
||||
return HTMLResponse(
|
||||
template.render(request=request, active_nav="input", institutions=INSTITUTIONS)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def submit_position(
|
||||
request: Request,
|
||||
institution: str = Form(...),
|
||||
direction: str = Form(...),
|
||||
date_str: str = Form(...),
|
||||
position: int = Form(...),
|
||||
avg_cost: float = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
d = date.fromisoformat(date_str)
|
||||
|
||||
prev = (
|
||||
db.query(PositionSnapshot)
|
||||
.filter(
|
||||
PositionSnapshot.institution == institution,
|
||||
PositionSnapshot.direction == direction,
|
||||
PositionSnapshot.date < d,
|
||||
)
|
||||
.order_by(PositionSnapshot.date.desc())
|
||||
.first()
|
||||
)
|
||||
delta = position - prev.position if prev else 0
|
||||
|
||||
existing = (
|
||||
db.query(PositionSnapshot)
|
||||
.filter(
|
||||
PositionSnapshot.institution == institution,
|
||||
PositionSnapshot.direction == direction,
|
||||
PositionSnapshot.date == d,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.position = position
|
||||
existing.delta = delta
|
||||
existing.avg_cost = avg_cost
|
||||
else:
|
||||
snap = PositionSnapshot(
|
||||
institution=institution,
|
||||
direction=direction,
|
||||
date=d,
|
||||
position=position,
|
||||
delta=delta,
|
||||
avg_cost=avg_cost,
|
||||
)
|
||||
db.add(snap)
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse("/analysis", status_code=303)
|
||||
@@ -0,0 +1,149 @@
|
||||
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 看跌")]
|
||||
OPTION_DIRECTIONS = [("buy", "买"), ("sell", "卖")]
|
||||
|
||||
|
||||
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,
|
||||
option_directions=OPTION_DIRECTIONS,
|
||||
open_trades=open_trades,
|
||||
closed_trades=closed_trades,
|
||||
today=today_str(),
|
||||
weekdays=["周一","周二","周三","周四","周五","周六","周日"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/open")
|
||||
def open_trade(
|
||||
request: Request,
|
||||
contract_code: str = Form(...),
|
||||
option_type: str = Form(...),
|
||||
direction: 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,
|
||||
direction=direction,
|
||||
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}/edit")
|
||||
def edit_trade(
|
||||
request: Request,
|
||||
trade_id: int,
|
||||
contract_code: str = Form(...),
|
||||
option_type: str = Form(...),
|
||||
direction: str = Form(...),
|
||||
strike_price: float = Form(...),
|
||||
open_date: str = Form(...),
|
||||
open_price: float = Form(...),
|
||||
open_fee: float = Form(0.0),
|
||||
close_date: str = Form(""),
|
||||
close_price: str = Form(""),
|
||||
close_fee: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
|
||||
if t:
|
||||
code = contract_code.upper()
|
||||
contract = db.query(Contract).filter(Contract.code == code).first()
|
||||
t.product_code = contract.product.code if contract else code[:2]
|
||||
t.contract_code = code
|
||||
t.option_type = option_type
|
||||
t.direction = direction
|
||||
t.strike_price = strike_price
|
||||
t.open_date = date.fromisoformat(open_date)
|
||||
t.open_price = open_price
|
||||
t.open_fee = open_fee
|
||||
if close_date and close_price:
|
||||
t.close_date = date.fromisoformat(close_date)
|
||||
t.close_price = float(close_price)
|
||||
t.close_fee = float(close_fee) if close_fee else 0.0
|
||||
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)
|
||||
@@ -0,0 +1,175 @@
|
||||
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 Round, Position, Contract, Product
|
||||
|
||||
router = APIRouter(prefix="/positions", tags=["positions"])
|
||||
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def positions_page(request: Request, db: Session = Depends(get_db)):
|
||||
active_rounds = (
|
||||
db.query(Round)
|
||||
.filter(Round.status == "active")
|
||||
.order_by(Round.started_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
template = request.app.state.templates.get_template("positions.html")
|
||||
return HTMLResponse(
|
||||
template.render(
|
||||
request=request,
|
||||
active_nav="positions",
|
||||
contracts=get_active_contracts(db),
|
||||
product_contracts=get_product_contracts(db),
|
||||
active_rounds=active_rounds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/round")
|
||||
def create_round(
|
||||
request: Request,
|
||||
contract_code: str = Form(...),
|
||||
daily_hands: int = Form(1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
code = contract_code.upper()
|
||||
existing = db.query(Round).filter(
|
||||
Round.status == "active", Round.contract_code == code
|
||||
).first()
|
||||
if existing:
|
||||
return RedirectResponse(f"/positions/?error={code} 已有活跃轮次", status_code=303)
|
||||
|
||||
r = Round(
|
||||
contract_code=code,
|
||||
daily_hands=daily_hands,
|
||||
status="active",
|
||||
started_at=date.today(),
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{round_id}/add")
|
||||
def add_position(
|
||||
request: Request,
|
||||
round_id: int,
|
||||
open_price: int = Form(...),
|
||||
amp_threshold: int = Form(...),
|
||||
lock_price: str = Form(""),
|
||||
hands: int = Form(1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
r = db.query(Round).filter(Round.id == round_id).first()
|
||||
if not r or r.status != "active":
|
||||
return RedirectResponse("/positions/?error=轮次不存在或已结束", status_code=303)
|
||||
|
||||
final_lock = int(lock_price) if lock_price else open_price + amp_threshold
|
||||
p = Position(
|
||||
round_id=r.id,
|
||||
open_price=open_price,
|
||||
amp_threshold=amp_threshold,
|
||||
lock_price=final_lock,
|
||||
hands=hands,
|
||||
opened_at=date.today(),
|
||||
)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{position_id}/lock")
|
||||
def lock_position(
|
||||
position_id: int,
|
||||
lock_price: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = db.query(Position).filter(Position.id == position_id).first()
|
||||
if p and p.locked_count < p.hands:
|
||||
if lock_price:
|
||||
p.lock_price = int(lock_price)
|
||||
p.locked_count += 1
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{position_id}/unlock")
|
||||
def unlock_position(position_id: int, db: Session = Depends(get_db)):
|
||||
p = db.query(Position).filter(Position.id == position_id).first()
|
||||
if p and p.locked_count > 0:
|
||||
p.locked_count -= 1
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{position_id}/update-lock-price")
|
||||
def update_lock_price(
|
||||
position_id: int,
|
||||
lock_price: int = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = db.query(Position).filter(Position.id == position_id).first()
|
||||
if p and p.locked_count == 0:
|
||||
p.lock_price = lock_price
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{round_id}/max-locks")
|
||||
def update_max_locks(
|
||||
round_id: int,
|
||||
max_locks: int = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
r = db.query(Round).filter(Round.id == round_id).first()
|
||||
if r and r.status == "active" and 1 <= max_locks <= 10:
|
||||
r.max_locks = max_locks
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{round_id}/close")
|
||||
def close_round(
|
||||
request: Request,
|
||||
round_id: int,
|
||||
result: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
r = db.query(Round).filter(Round.id == round_id).first()
|
||||
if r and r.status == "active":
|
||||
r.status = result
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{round_id}/delete")
|
||||
def delete_round(round_id: int, db: Session = Depends(get_db)):
|
||||
r = db.query(Round).filter(Round.id == round_id).first()
|
||||
if r:
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
return RedirectResponse("/positions/", status_code=303)
|
||||
@@ -0,0 +1,150 @@
|
||||
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, Contract, Product
|
||||
|
||||
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_product_contracts(db: Session) -> dict:
|
||||
"""Return {product_code: [contract_codes]} for active contracts."""
|
||||
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 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),
|
||||
product_contracts=get_product_contracts(db),
|
||||
directions=DIRECTIONS,
|
||||
open_trades=open_trades,
|
||||
closed_trades=closed_trades,
|
||||
today=today_str(),
|
||||
weekdays=["周一","周二","周三","周四","周五","周六","周日"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/open")
|
||||
def open_trade(
|
||||
request: Request,
|
||||
contract_code: str = Form(...),
|
||||
direction: str = 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 = Trade(
|
||||
product_code=product_code,
|
||||
contract_code=code,
|
||||
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: float = Form(...),
|
||||
close_fee: float = Form(0.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}/edit")
|
||||
def edit_trade(
|
||||
request: Request,
|
||||
trade_id: int,
|
||||
contract_code: str = Form(...),
|
||||
direction: str = Form(...),
|
||||
open_date: str = Form(...),
|
||||
open_price: float = Form(...),
|
||||
open_fee: float = Form(0.0),
|
||||
close_date: str = Form(""),
|
||||
close_price: str = Form(""),
|
||||
close_fee: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
t = db.query(Trade).filter(Trade.id == trade_id).first()
|
||||
if t:
|
||||
code = contract_code.upper()
|
||||
contract = db.query(Contract).filter(Contract.code == code).first()
|
||||
t.product_code = contract.product.code if contract else code[:2]
|
||||
t.contract_code = code
|
||||
t.direction = direction
|
||||
t.open_date = date.fromisoformat(open_date)
|
||||
t.open_price = open_price
|
||||
t.open_fee = open_fee
|
||||
if close_date and close_price:
|
||||
t.close_date = date.fromisoformat(close_date)
|
||||
t.close_price = float(close_price)
|
||||
t.close_fee = float(close_fee) if close_fee else 0.0
|
||||
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)
|
||||
+26
-198
@@ -1,7 +1,7 @@
|
||||
"""Seed database from existing data files. Run once manually or on first start."""
|
||||
from datetime import date
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.models import DailyBar, PositionSnapshot, Product, Contract
|
||||
from app.models import DailyBar, Product, Contract, User
|
||||
from app.engine.lock_strategy import compute_amp_5d
|
||||
|
||||
# --- Seed OHLCV data ---
|
||||
@@ -27,181 +27,37 @@ SEED_BARS: list[dict] = [
|
||||
{"date": "2026-07-24", "open": 900, "close": 908, "high": 912, "low": 891},
|
||||
]
|
||||
|
||||
# --- Position snapshots (from info.txt) ---
|
||||
POSITION_DATA_SHORT: list[dict] = [
|
||||
("中信期货", "short", "2026-07-22", 193030, -22032, 995.26),
|
||||
("中信期货", "short", "2026-07-21", 215062, -1029, 995.26),
|
||||
("中信期货", "short", "2026-07-20", 216091, -18178, 995.26),
|
||||
("中信期货", "short", "2026-07-17", 234269, -31, 995.26),
|
||||
("中信期货", "short", "2026-07-16", 234300, 35258, 995.26),
|
||||
("中信期货", "short", "2026-07-15", 199042, 5294, 1005.40),
|
||||
("中信期货", "short", "2026-07-14", 193748, 4117, 1006.94),
|
||||
("中信期货", "short", "2026-07-13", 189631, 18219, 1008.16),
|
||||
("中信期货", "short", "2026-07-10", 171412, -17319, 1013.49),
|
||||
("中信期货", "short", "2026-07-09", 188731, 9860, 1013.49),
|
||||
("中信期货", "short", "2026-07-08", 178871, -19747, 1016.71),
|
||||
("中信期货", "short", "2026-07-07", 198618, 25262, 1016.71),
|
||||
("中信期货", "short", "2026-07-06", 173356, 2998, 1025.41),
|
||||
("中信期货", "short", "2026-07-03", 170358, -11691, 1026.30),
|
||||
("中信期货", "short", "2026-07-02", 182049, 1284, 1026.30),
|
||||
("中信期货", "long", "2026-07-22", 63971, -10850, 988.16),
|
||||
("中信期货", "long", "2026-07-21", 74821, 2587, 988.16),
|
||||
("中信期货", "long", "2026-07-20", 72234, -84, 991.28),
|
||||
("中信期货", "long", "2026-07-17", 72318, 6928, 991.28),
|
||||
("中信期货", "long", "2026-07-16", 65390, 6950, 999.15),
|
||||
("中信期货", "long", "2026-07-15", 58440, -1227, 1006.42),
|
||||
("中信期货", "long", "2026-07-14", 59667, 887, 1006.42),
|
||||
("中信期货", "long", "2026-07-13", 58780, -4908, 1007.26),
|
||||
("中信期货", "long", "2026-07-10", 63688, 5795, 1007.26),
|
||||
("中信期货", "long", "2026-07-09", 57893, -3193, 1011.59),
|
||||
("中信期货", "long", "2026-07-08", 61086, 2092, 1011.59),
|
||||
("中信期货", "long", "2026-07-07", 58994, 8116, 1013.45),
|
||||
("中信期货", "long", "2026-07-06", 50878, -14738, 1022.46),
|
||||
("中信期货", "long", "2026-07-03", 65616, 9777, 1022.46),
|
||||
("中信期货", "long", "2026-07-02", 55839, -2024, 1031.30),
|
||||
("高盛期货", "short", "2026-07-22", 188460, -14927, 997.89),
|
||||
("高盛期货", "short", "2026-07-21", 203387, -15903, 997.89),
|
||||
("高盛期货", "short", "2026-07-20", 219290, 1220, 997.89),
|
||||
("高盛期货", "short", "2026-07-17", 218070, 14452, 998.44),
|
||||
("高盛期货", "short", "2026-07-16", 203618, 12879, 1004.22),
|
||||
("高盛期货", "short", "2026-07-15", 190739, -5701, 1008.69),
|
||||
("高盛期货", "short", "2026-07-14", 196440, 1711, 1008.69),
|
||||
("高盛期货", "short", "2026-07-13", 194729, 418, 1009.20),
|
||||
("高盛期货", "short", "2026-07-10", 194311, -5203, 1009.31),
|
||||
("高盛期货", "short", "2026-07-09", 199514, 7653, 1009.31),
|
||||
("高盛期货", "short", "2026-07-08", 191861, 1394, 1011.47),
|
||||
("高盛期货", "short", "2026-07-07", 190467, 12046, 1011.86),
|
||||
("高盛期货", "short", "2026-07-06", 178421, 578, 1015.56),
|
||||
("高盛期货", "short", "2026-07-03", 177843, 1049, 1015.69),
|
||||
("高盛期货", "short", "2026-07-02", 176794, 19121, 1015.95),
|
||||
("国泰君安期货", "short", "2026-07-22", 168449, -9289, 1018.54),
|
||||
("国泰君安期货", "short", "2026-07-21", 177738, -10380, 1018.54),
|
||||
("国泰君安期货", "short", "2026-07-20", 188118, -731, 1018.54),
|
||||
("国泰君安期货", "short", "2026-07-17", 188849, -12984, 1018.54),
|
||||
("国泰君安期货", "short", "2026-07-16", 201833, -5144, 1018.54),
|
||||
("国泰君安期货", "short", "2026-07-15", 206977, 2787, 1018.54),
|
||||
("国泰君安期货", "short", "2026-07-14", 204190, -2399, 1019.49),
|
||||
("国泰君安期货", "short", "2026-07-13", 206589, 24344, 1019.49),
|
||||
("国泰君安期货", "short", "2026-07-10", 182245, -4391, 1027.70),
|
||||
("国泰君安期货", "short", "2026-07-09", 186636, -6510, 1027.70),
|
||||
("国泰君安期货", "short", "2026-07-08", 193146, -6957, 1027.70),
|
||||
("国泰君安期货", "short", "2026-07-07", 200103, 9696, 1027.70),
|
||||
("国泰君安期货", "short", "2026-07-06", 190407, 1344, 1031.30),
|
||||
("国泰君安期货", "short", "2026-07-03", 189063, -1588, 1031.70),
|
||||
("国泰君安期货", "short", "2026-07-02", 190651, 4656, 1031.70),
|
||||
("国泰君安期货", "long", "2026-07-22", 144325, -6262, 991.07),
|
||||
("国泰君安期货", "long", "2026-07-21", 150587, 17790, 991.07),
|
||||
("国泰君安期货", "long", "2026-07-20", 132797, 3578, 1003.13),
|
||||
("国泰君安期货", "long", "2026-07-17", 129219, 12066, 1005.99),
|
||||
("国泰君安期货", "long", "2026-07-16", 117153, 8387, 1015.15),
|
||||
("国泰君安期货", "long", "2026-07-15", 108766, 5534, 1021.10),
|
||||
("国泰君安期货", "long", "2026-07-14", 103232, 4815, 1024.97),
|
||||
("国泰君安期货", "long", "2026-07-13", 98417, 4839, 1028.58),
|
||||
("国泰君安期货", "long", "2026-07-10", 93578, -1293, 1032.23),
|
||||
("国泰君安期货", "long", "2026-07-09", 94871, -807, 1032.23),
|
||||
("国泰君安期货", "long", "2026-07-08", 95678, -5169, 1032.23),
|
||||
("国泰君安期货", "long", "2026-07-07", 100847, 3412, 1032.23),
|
||||
("国泰君安期货", "long", "2026-07-06", 97435, -4615, 1034.87),
|
||||
("国泰君安期货", "long", "2026-07-03", 102050, 2169, 1034.87),
|
||||
("国泰君安期货", "long", "2026-07-02", 99881, 9801, 1036.23),
|
||||
("华泰期货", "short", "2026-07-22", 80564, -8762, 976.69),
|
||||
("华泰期货", "short", "2026-07-21", 89326, 9844, 976.69),
|
||||
("华泰期货", "short", "2026-07-20", 79482, 1751, 986.06),
|
||||
("华泰期货", "short", "2026-07-17", 77731, -8367, 988.00),
|
||||
("华泰期货", "short", "2026-07-16", 86098, 11358, 988.00),
|
||||
("华泰期货", "short", "2026-07-15", 74740, 3028, 995.60),
|
||||
("华泰期货", "short", "2026-07-14", 71712, -2199, 997.57),
|
||||
("华泰期货", "short", "2026-07-13", 73911, 10715, 997.57),
|
||||
("华泰期货", "short", "2026-07-10", 63196, -3512, 1004.28),
|
||||
("华泰期货", "short", "2026-07-09", 66708, 3827, 1004.28),
|
||||
("华泰期货", "short", "2026-07-08", 62881, -10077, 1007.27),
|
||||
("华泰期货", "short", "2026-07-07", 72958, 12276, 1007.27),
|
||||
("华泰期货", "short", "2026-07-06", 60682, -6933, 1017.45),
|
||||
("华泰期货", "short", "2026-07-03", 65615, -5963, 1017.45),
|
||||
("华泰期货", "short", "2026-07-02", 71558, -2128, 1017.45),
|
||||
("华泰期货", "long", "2026-07-22", 68791, -5130, 1002.62),
|
||||
("华泰期货", "long", "2026-07-21", 73921, -274, 1002.62),
|
||||
("华泰期货", "long", "2026-07-20", 74195, 840, 1002.62),
|
||||
("华泰期货", "long", "2026-07-17", 73355, 7012, 1003.79),
|
||||
("华泰期货", "long", "2026-07-16", 66343, 4562, 1012.97),
|
||||
("华泰期货", "long", "2026-07-15", 61781, -1562, 1018.50),
|
||||
("华泰期货", "long", "2026-07-14", 63343, 2364, 1018.50),
|
||||
("华泰期货", "long", "2026-07-13", 60979, -1843, 1021.12),
|
||||
("华泰期货", "long", "2026-07-10", 62822, 1161, 1021.12),
|
||||
("华泰期货", "long", "2026-07-09", 61661, 162, 1022.19),
|
||||
("华泰期货", "long", "2026-07-08", 61499, 82, 1022.37),
|
||||
("华泰期货", "long", "2026-07-07", 61417, 6050, 1022.46),
|
||||
("华泰期货", "long", "2026-07-06", 55367, -2699, 1029.61),
|
||||
("华泰期货", "long", "2026-07-03", 58066, -4006, 1029.61),
|
||||
("华泰期货", "long", "2026-07-02", 62072, -2935, 1029.61),
|
||||
("东证期货", "short", "2026-07-22", 112614, -18937, 965.67),
|
||||
("东证期货", "short", "2026-07-21", 131551, -45486, 965.67),
|
||||
("东证期货", "short", "2026-07-20", 177037, -1629, 965.67),
|
||||
("东证期货", "short", "2026-07-17", 178666, -4252, 965.67),
|
||||
("东证期货", "short", "2026-07-16", 182918, 47376, 965.67),
|
||||
("东证期货", "short", "2026-07-15", 135542, -13505, 975.35),
|
||||
("东证期货", "short", "2026-07-14", 149047, -1108, 975.35),
|
||||
("东证期货", "short", "2026-07-13", 150155, 40931, 975.35),
|
||||
("东证期货", "short", "2026-07-10", 109224, -23556, 981.85),
|
||||
("东证期货", "short", "2026-07-09", 132780, 25017, 981.85),
|
||||
("东证期货", "short", "2026-07-08", 107763, -56346, 988.08),
|
||||
("东证期货", "short", "2026-07-07", 164109, 37956, 988.08),
|
||||
("东证期货", "short", "2026-07-06", 126153, 20536, 997.43),
|
||||
("东证期货", "short", "2026-07-03", 105617, -10003, 1001.79),
|
||||
("东证期货", "short", "2026-07-02", 115620, -16409, 1001.79),
|
||||
("东证期货", "long", "2026-07-22", 85630, 6685, 965.35),
|
||||
("东证期货", "long", "2026-07-21", 78945, 8359, 970.20),
|
||||
("东证期货", "long", "2026-07-20", 70586, 2334, 978.40),
|
||||
("东证期货", "long", "2026-07-17", 68252, -5918, 981.08),
|
||||
("东证期货", "long", "2026-07-16", 74170, 10413, 981.08),
|
||||
("东证期货", "long", "2026-07-15", 63757, 1640, 988.12),
|
||||
("东证期货", "long", "2026-07-14", 62117, -2136, 989.15),
|
||||
("东证期货", "long", "2026-07-13", 64253, -14158, 989.15),
|
||||
("东证期货", "long", "2026-07-10", 78411, 7829, 989.15),
|
||||
("东证期货", "long", "2026-07-09", 70582, -9231, 991.94),
|
||||
("东证期货", "long", "2026-07-08", 79813, 11598, 991.94),
|
||||
("东证期货", "long", "2026-07-07", 68215, 10382, 997.54),
|
||||
("东证期货", "long", "2026-07-06", 57833, -30335, 1004.82),
|
||||
("东证期货", "long", "2026-07-03", 88168, 20705, 1004.82),
|
||||
("东证期货", "long", "2026-07-02", 67463, 3862, 1014.89),
|
||||
("银河期货", "short", "2026-07-22", 62130, -3771, 1022.69),
|
||||
("银河期货", "short", "2026-07-21", 65901, -3865, 1022.69),
|
||||
("银河期货", "short", "2026-07-20", 69766, 1572, 1022.69),
|
||||
("银河期货", "short", "2026-07-17", 68194, 431, 1025.51),
|
||||
("银河期货", "short", "2026-07-16", 67763, -936, 1026.20),
|
||||
("银河期货", "short", "2026-07-15", 68699, 372, 1026.20),
|
||||
("银河期货", "short", "2026-07-14", 68327, 5179, 1026.62),
|
||||
("银河期货", "short", "2026-07-13", 63148, -3119, 1032.83),
|
||||
("银河期货", "short", "2026-07-10", 66267, -431, 1032.83),
|
||||
("银河期货", "short", "2026-07-09", 66698, 1995, 1032.83),
|
||||
("银河期货", "short", "2026-07-08", 64703, -5025, 1035.23),
|
||||
("银河期货", "short", "2026-07-07", 69728, 6103, 1035.23),
|
||||
("银河期货", "short", "2026-07-06", 63625, -1703, 1042.73),
|
||||
("银河期货", "short", "2026-07-03", 65328, -3861, 1042.73),
|
||||
("银河期货", "short", "2026-07-02", 69189, 1875, 1042.73),
|
||||
("银河期货", "long", "2026-07-22", 57020, -7565, 1016.17),
|
||||
("银河期货", "long", "2026-07-21", 64585, -4812, 1016.17),
|
||||
("银河期货", "long", "2026-07-20", 69397, -2877, 1016.17),
|
||||
("银河期货", "long", "2026-07-17", 72274, -4410, 1016.17),
|
||||
("银河期货", "long", "2026-07-16", 76684, 4888, 1016.17),
|
||||
("银河期货", "long", "2026-07-15", 71796, -3235, 1021.49),
|
||||
("银河期货", "long", "2026-07-14", 75031, -456, 1021.49),
|
||||
("银河期货", "long", "2026-07-13", 75487, 4361, 1021.49),
|
||||
("银河期货", "long", "2026-07-10", 71126, -6491, 1025.39),
|
||||
("银河期货", "long", "2026-07-09", 77617, 376, 1025.39),
|
||||
("银河期货", "long", "2026-07-08", 77241, -3621, 1025.73),
|
||||
("银河期货", "long", "2026-07-07", 80862, 10307, 1025.73),
|
||||
("银河期货", "long", "2026-07-06", 70555, 2439, 1035.77),
|
||||
("银河期货", "long", "2026-07-03", 68116, -4101, 1037.95),
|
||||
("银河期货", "long", "2026-07-02", 72217, 490, 1037.95),
|
||||
]
|
||||
def _migrate(engine):
|
||||
"""Add missing columns to existing tables (SQLite doesn't auto-migrate)."""
|
||||
import sqlite3
|
||||
conn = engine.raw_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(rounds)")
|
||||
cols = {row[1] for row in cur.fetchall()}
|
||||
if "max_locks" not in cols:
|
||||
cur.execute("ALTER TABLE rounds ADD COLUMN max_locks INTEGER DEFAULT 3")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def seed():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate(engine)
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# --- Seed default user ---
|
||||
if not db.query(User).first():
|
||||
admin = User(
|
||||
username="admin",
|
||||
password_hash=User.hash_password("admin123"),
|
||||
role="admin",
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
# --- Seed products and contracts ---
|
||||
if not db.query(Product).first():
|
||||
fg = Product(code="FG", name="玻璃", exchange="CZCE")
|
||||
@@ -250,36 +106,8 @@ def seed():
|
||||
if i >= 5:
|
||||
bar.amp_5d = compute_amp_5d([b.diff for b in seed_bars[i - 5 : i]])
|
||||
|
||||
# --- Seed position snapshots ---
|
||||
existing_pos = {
|
||||
(r.institution, r.direction, r.date)
|
||||
for r in db.query(
|
||||
PositionSnapshot.institution,
|
||||
PositionSnapshot.direction,
|
||||
PositionSnapshot.date,
|
||||
).all()
|
||||
}
|
||||
|
||||
snaps = []
|
||||
for inst, direction, date_str, pos, delta, cost in POSITION_DATA_SHORT:
|
||||
d = date.fromisoformat(date_str)
|
||||
if (inst, direction, d) not in existing_pos:
|
||||
snaps.append(
|
||||
PositionSnapshot(
|
||||
institution=inst,
|
||||
direction=direction,
|
||||
date=d,
|
||||
position=pos,
|
||||
delta=delta,
|
||||
avg_cost=cost,
|
||||
)
|
||||
)
|
||||
|
||||
if snaps:
|
||||
db.add_all(snaps)
|
||||
|
||||
db.commit()
|
||||
print(f"Seeded {len(bars_to_insert)} bars + {len(snaps)} position snapshots")
|
||||
print(f"Seeded {len(bars_to_insert)} bars")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+281
-42
@@ -5,20 +5,89 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
|
||||
{# ── Sync Bar ── #}
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
|
||||
<span style="font-size:0.85rem;color:var(--sub);">数据同步</span>
|
||||
<form method="post" action="/admin/sync" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步全部合约</button>
|
||||
{% set tab = request.query_params.get('tab', 'sync') %}
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn{% if tab == 'sync' %} active{% endif %}" onclick="switchTab('sync')">📡 数据同步</button>
|
||||
<button class="tab-btn{% if tab == 'product' %} active{% endif %}" onclick="switchTab('product')">🏷️ 品种管理</button>
|
||||
<button class="tab-btn{% if tab == 'contract' %} active{% endif %}" onclick="switchTab('contract')">📋 合约管理</button>
|
||||
</div>
|
||||
|
||||
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
|
||||
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
|
||||
<span style="font-size:0.78rem;color:var(--sub);">共 {{ total_contracts }} 个合约</span>
|
||||
</div>
|
||||
|
||||
<div id="sync-progress" style="display:none;margin-bottom:18px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:8px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||
<span id="sync-progress-label" style="font-size:0.82rem;color:var(--fg);">正在同步...</span>
|
||||
<span id="sync-progress-pct" style="font-size:0.8rem;color:var(--sub);margin-left:auto;"></span>
|
||||
</div>
|
||||
<div style="width:100%;height:5px;background:var(--border);border-radius:3px;overflow:hidden;">
|
||||
<div id="sync-progress-bar" style="width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width .3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if products %}
|
||||
{% for p in products %}
|
||||
<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
|
||||
<div class="product-header"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--th-bg);cursor:pointer;user-select:none;"
|
||||
onclick="toggleProduct(this)">
|
||||
<span class="collapse-arrow" style="font-size:0.75rem;transition:transform .2s;display:inline-block;transform:rotate(-90deg);">▼</span>
|
||||
<strong style="font-size:0.92rem;">{{ p.code }} · {{ p.name }}</strong>
|
||||
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
|
||||
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
|
||||
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步行情</button>
|
||||
</form>
|
||||
{% if request.query_params.get('synced') %}
|
||||
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 已同步 {{ request.query_params.synced }} 条</span>
|
||||
</div>
|
||||
<div class="product-body" style="display:none;">
|
||||
<table style="font-size:0.84rem;">
|
||||
<tr><th style="text-align:left;padding:8px 14px;">合约</th><th style="text-align:center;padding:8px 14px;">行情</th><th style="text-align:center;padding:8px 14px;">持仓</th><th style="text-align:center;padding:8px 14px;">状态</th><th style="text-align:center;padding:8px 14px;">操作</th></tr>
|
||||
{% for c in p.contracts %}
|
||||
<tr>
|
||||
<td style="text-align:left;padding:6px 14px;"><strong>{{ c.code }}</strong></td>
|
||||
<td style="text-align:center;padding:6px 14px;">{{ c.bar_count }}</td>
|
||||
<td style="text-align:center;padding:6px 14px;">{{ c.pos_days }}</td>
|
||||
<td style="text-align:center;padding:6px 14px;">
|
||||
{% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;padding:6px 14px;">
|
||||
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
|
||||
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;margin-left:12px;" onclick="syncBars('{{ c.code }}')">↻ 行情</button>
|
||||
<button style="background:none;border:none;color:var(--success);cursor:pointer;font-size:0.82rem;margin-left:10px;" onclick="syncPositions('{{ c.code }}')">↻ 持仓</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先在「品种管理」中新建。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# ── New Product ── #}
|
||||
<div class="section-title">新建品种</div>
|
||||
|
||||
{# ═══════════════════ Tab: 品种管理 ═══════════════════ #}
|
||||
<div class="tab-panel{% if tab == 'product' %} active{% endif %}" id="tab-product">
|
||||
<div class="form-card" style="margin-bottom:24px;">
|
||||
<div class="section-title">新建品种</div>
|
||||
<form method="post" action="/admin/product" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
|
||||
<label>品种代码</label>
|
||||
@@ -40,47 +109,73 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Product List ── #}
|
||||
{% if products %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr><th>代码</th><th>名称</th><th>交易所</th><th>合约数</th><th>操作</th></tr>
|
||||
{% for p in products %}
|
||||
<div class="section-title">{{ p.code }} · {{ p.name }} <span style="font-weight:400;color:var(--sub);font-size:0.78rem;">{{ p.exchange }}</span></div>
|
||||
<tr>
|
||||
<td><strong>{{ p.code }}</strong></td>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.exchange }}</td>
|
||||
<td>{{ p.contracts|length }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="display:inline;">
|
||||
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── New Contract ── #}
|
||||
<div class="form-card" style="margin-bottom:12px;">
|
||||
{# ═══════════════════ Tab: 合约管理 ═══════════════════ #}
|
||||
<div class="tab-panel{% if tab == 'contract' %} active{% endif %}" id="tab-contract">
|
||||
{% if products %}
|
||||
{# ── Add contract form ── #}
|
||||
<div class="form-card" style="margin-bottom:24px;">
|
||||
<div class="section-title">添加合约</div>
|
||||
<form method="post" action="/admin/contract" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<input type="hidden" name="product_id" value="{{ p.id }}">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
|
||||
<label>合约代码</label>
|
||||
<input type="text" name="code" required placeholder="{{ p.code }}2609" maxlength="10" style="text-transform:uppercase;">
|
||||
<label>品种</label>
|
||||
<select name="product_id" required>
|
||||
<option value="">-- 选择品种 --</option>
|
||||
{% for p in products %}
|
||||
<option value="{{ p.id }}">{{ p.code }} · {{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0;flex:2;min-width:160px;">
|
||||
<label>合约名称</label>
|
||||
<input type="text" name="name" required placeholder="{{ p.name }}2609">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
|
||||
<label>月份 <span style="font-weight:400;color:var(--sub);font-size:0.75rem;">如 2609</span></label>
|
||||
<input type="text" name="month" required placeholder="2609" maxlength="4" style="text-transform:uppercase;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">添加合约</button>
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Contract List ── #}
|
||||
{% if p.contracts %}
|
||||
{# ── Product tabs for contract list ── #}
|
||||
{% set ctab = request.query_params.get('ctab', products[0].code if products else '') %}
|
||||
<div class="tabs" style="margin-top:24px;">
|
||||
{% for p in products %}
|
||||
<a href="/admin/?tab=contract&ctab={{ p.code }}" class="tab-btn{% if ctab == p.code %} active{% endif %}">{{ p.code }} · {{ p.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for p in products %}
|
||||
{% if p.code == ctab and p.contracts %}
|
||||
<div class="table-wrap" style="margin-bottom:20px;">
|
||||
<table>
|
||||
<tr><th>合约代码</th><th>名称</th><th>状态</th><th>行情</th><th>操作</th></tr>
|
||||
<tr><th>合约代码</th><th>月份</th><th>状态</th><th>操作</th></tr>
|
||||
{% for c in p.contracts %}
|
||||
<tr>
|
||||
<td><strong>{{ c.code }}</strong></td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>
|
||||
{% if c.is_active %}
|
||||
<span class="badge badge-up">启用</span>
|
||||
{% else %}
|
||||
<span class="badge badge-down">停用</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;">查看</a>
|
||||
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
|
||||
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.78rem;">↻ 同步</button>
|
||||
</form>
|
||||
{% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/contract/{{ c.id }}/toggle" style="display:inline;">
|
||||
@@ -88,7 +183,7 @@
|
||||
{% if c.is_active %}停用{% else %}启用{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}?此操作不可恢复。')">
|
||||
<form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}?')">
|
||||
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -97,13 +192,157 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="text-align:right;margin-bottom:28px;">
|
||||
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除品种 {{ p.code }}</button>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先在「品种管理」中新建。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function escapeHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
var syncTimer = null;
|
||||
var progressShownAt = 0;
|
||||
|
||||
function showProgress() {
|
||||
progressShownAt = Date.now();
|
||||
document.getElementById('sync-progress').style.display = 'block';
|
||||
document.getElementById('sync-progress-bar').style.width = '0%';
|
||||
document.getElementById('sync-progress-pct').textContent = '';
|
||||
document.getElementById('sync-progress-label').textContent = '正在同步...';
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
document.getElementById('sync-progress').style.display = 'none';
|
||||
}
|
||||
|
||||
function pollProgress() {
|
||||
fetch('/admin/sync-status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0;
|
||||
document.getElementById('sync-progress-bar').style.width = pct + '%';
|
||||
document.getElementById('sync-progress-pct').textContent = pct + '%';
|
||||
document.getElementById('sync-progress-label').textContent = data.label;
|
||||
|
||||
if (data.error) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
document.getElementById('sync-progress-bar').style.background = 'var(--danger)';
|
||||
document.getElementById('sync-progress-label').innerHTML =
|
||||
'<span style="color:var(--danger-fg);">✕ ' + escapeHtml(data.label) + '</span>' +
|
||||
'<span style="display:block;font-size:0.78rem;color:var(--danger-fg);margin-top:4px;">' + escapeHtml(data.error) + '</span>';
|
||||
document.getElementById('sync-progress-pct').textContent = '';
|
||||
document.getElementById('sync-progress').insertAdjacentHTML('beforeend',
|
||||
'<button onclick="hideProgress()" style="display:block;margin-top:10px;padding:4px 14px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.8rem;">关闭</button>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.finished) {
|
||||
var elapsed = Date.now() - progressShownAt;
|
||||
var delay = elapsed < 1000 ? 1000 - elapsed : 0;
|
||||
setTimeout(function() {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
document.getElementById('sync-progress-bar').style.width = '100%';
|
||||
if (data.result > 0) {
|
||||
document.getElementById('sync-progress-label').textContent = data.label + ' ✓ · +' + data.result;
|
||||
} else if (data.total > 0) {
|
||||
document.getElementById('sync-progress-label').textContent = data.label + ' ⚠ 无新数据,历史日期无可用持仓';
|
||||
} else {
|
||||
document.getElementById('sync-progress-label').textContent = data.label + ' ✓ 已是最新';
|
||||
}
|
||||
document.getElementById('sync-progress-pct').textContent = '';
|
||||
document.getElementById('sync-progress').insertAdjacentHTML('beforeend',
|
||||
'<button onclick="hideProgress()" style="display:block;margin-top:10px;padding:4px 14px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.8rem;">关闭</button>');
|
||||
if (data.result > 0) {
|
||||
var expanded = [];
|
||||
document.querySelectorAll('.product-body').forEach(function(b, i) {
|
||||
if (b.style.display === 'block') expanded.push(i);
|
||||
});
|
||||
try { sessionStorage.setItem('ft-expanded', JSON.stringify(expanded)); } catch(e) {}
|
||||
setTimeout(function() { location.reload(); }, 1200);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startSync(url) {
|
||||
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
|
||||
showProgress();
|
||||
fetch(url, { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) {
|
||||
// Another sync is already running — just show its progress
|
||||
syncTimer = setInterval(pollProgress, 500);
|
||||
pollProgress();
|
||||
return;
|
||||
}
|
||||
syncTimer = setInterval(pollProgress, 500);
|
||||
});
|
||||
}
|
||||
|
||||
function syncBars(code) { startSync('/admin/sync/' + code + '/bg'); }
|
||||
function syncPositions(code) { startSync('/admin/sync-positions/' + code + '/bg'); }
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
|
||||
document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
var url = new URL(window.location);
|
||||
url.searchParams.set('tab', name);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
function toggleProduct(header) {
|
||||
var body = header.nextElementSibling;
|
||||
var arrow = header.querySelector('.collapse-arrow');
|
||||
if (body.style.display === 'none') {
|
||||
body.style.display = 'block';
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
body.style.display = 'none';
|
||||
arrow.style.transform = 'rotate(-90deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore expanded accordion state after reload
|
||||
(function() {
|
||||
try {
|
||||
var expanded = JSON.parse(sessionStorage.getItem('ft-expanded') || '[]');
|
||||
var bodies = document.querySelectorAll('.product-body');
|
||||
expanded.forEach(function(i) {
|
||||
if (bodies[i]) {
|
||||
bodies[i].style.display = 'block';
|
||||
var arrow = bodies[i].previousElementSibling.querySelector('.collapse-arrow');
|
||||
if (arrow) arrow.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
});
|
||||
sessionStorage.removeItem('ft-expanded');
|
||||
} catch(e) {}
|
||||
})();
|
||||
|
||||
// Auto-show progress bar if a sync is already running
|
||||
(function() {
|
||||
fetch('/admin/sync-status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.running) {
|
||||
showProgress();
|
||||
syncTimer = setInterval(pollProgress, 500);
|
||||
pollProgress();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}博弈分析{% endblock %}
|
||||
{% block heading %}博弈分析{% endblock %}
|
||||
{% block breadcrumb %}机构持仓{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if latest_date %}
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">数据日期</div>
|
||||
<div class="value">{{ latest_date }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">参考现价</div>
|
||||
<div class="value">{{ current_price }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">六家净空</div>
|
||||
<div class="value">{{ total_net_short }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">浮动盈亏</div>
|
||||
<div class="value">{{ total_pnl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">机构持仓明细</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr>
|
||||
<th>机构</th><th>多单</th><th>空单</th><th>多单成本</th><th>空单成本</th><th>净持仓</th><th>净盈亏</th>
|
||||
</tr>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><strong>{{ r.institution }}</strong></td>
|
||||
<td>{{ r.long_pos }}</td>
|
||||
<td>{{ r.short_pos }}</td>
|
||||
<td>{{ r.long_cost }}</td>
|
||||
<td>{{ r.short_cost }}</td>
|
||||
<td>{{ r.net_pos }}</td>
|
||||
<td>
|
||||
{% if r.pnl_raw > 0 %}
|
||||
<span class="badge badge-up">{{ r.pnl }}</span>
|
||||
{% elif r.pnl_raw < 0 %}
|
||||
<span class="badge badge-down">{{ r.pnl }}</span>
|
||||
{% else %}
|
||||
<span class="na">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr style="font-weight:700; background:var(--th-bg);">
|
||||
<td>合计</td>
|
||||
<td>{{ totals.long_pos }}</td>
|
||||
<td>{{ totals.short_pos }}</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>{{ totals.net_pos }}</td>
|
||||
<td>{{ totals.pnl }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:60px 0;color:var(--sub);">
|
||||
<p style="font-size:1.1rem;">暂无持仓数据</p>
|
||||
<p style="margin-top:8px;"><a href="/input/" style="color:var(--accent);">前往录入 →</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -59,6 +59,11 @@
|
||||
border-left-color: var(--accent); font-weight: 600;
|
||||
}
|
||||
.sidebar-nav .icon { font-size: 1.1rem; width: 22px; text-align: center; }
|
||||
.nav-group .nav-arrow { font-size: 0.65rem; margin-left: auto; transition: transform .2s; color: var(--sub); }
|
||||
.nav-group.open .nav-arrow { transform: rotate(-180deg); }
|
||||
.nav-group .nav-children { display: none; }
|
||||
.nav-group.open .nav-children { display: block; }
|
||||
.nav-children a { padding-left: 48px !important; font-size: 0.84rem !important; }
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px; border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -128,8 +133,8 @@
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.78rem; font-weight: 600; }
|
||||
.badge-up { background: var(--success-bg); color: var(--success-fg); }
|
||||
.badge-down { background: var(--danger-bg); color: var(--danger-fg); }
|
||||
.badge-up { background: var(--danger-bg); color: var(--danger-fg); }
|
||||
.badge-down { background: var(--success-bg); color: var(--success-fg); }
|
||||
.badge-warn { background: var(--warn-bg); color: var(--warn-fg); }
|
||||
|
||||
/* ── Forms ── */
|
||||
@@ -177,15 +182,18 @@
|
||||
.amp-cell { cursor: pointer; color: var(--accent); font-weight: 600; }
|
||||
.amp-cell:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Tags / Chips ── */
|
||||
.tag-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
||||
.tag {
|
||||
padding: 5px 12px; border-radius: 20px; font-size: 0.78rem;
|
||||
font-weight: 500; cursor: pointer; text-decoration: none;
|
||||
border: 1px solid var(--border); color: var(--sub);
|
||||
transition: all .12s;
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23475669' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag:hover, .tag.active { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
|
||||
[data-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
|
||||
filter: none;
|
||||
}
|
||||
[data-theme="dark"] input[type="date"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -195,18 +203,25 @@
|
||||
<a href="/contracts/" class="{% if active_nav == 'contracts' %}active{% endif %}">
|
||||
<span class="icon">📈</span> 行情数据
|
||||
</a>
|
||||
<a href="/analysis/" class="{% if active_nav == 'analysis' %}active{% endif %}">
|
||||
<span class="icon">⚔️</span> 博弈分析
|
||||
<a href="/positions/" class="{% if active_nav == 'positions' %}active{% endif %}">
|
||||
<span class="icon">📐</span> 对冲管理
|
||||
</a>
|
||||
<a href="/input/" class="{% if active_nav == 'input' %}active{% endif %}">
|
||||
<span class="icon">📝</span> 数据录入
|
||||
<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>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<button class="theme-btn" id="themeToggle">🌙 暗色模式</button>
|
||||
<div style="font-size:0.78rem;color:var(--sub);margin-bottom:8px;">
|
||||
👤 {{ request.state.user.username if request.state.user else '—' }}
|
||||
</div>
|
||||
<a href="/auth/logout" style="font-size:0.78rem;color:var(--sub);text-decoration:none;">退出登录</a>
|
||||
<button class="theme-btn" style="margin-top:8px;" id="themeToggle">🌙 暗色模式</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -235,9 +250,11 @@ themeBtn.addEventListener('click', toggleTheme);
|
||||
function setTheme(dark) {
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
themeBtn.textContent = '☀️ 亮色模式';
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
themeBtn.textContent = '🌙 暗色模式';
|
||||
}
|
||||
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
|
||||
@@ -245,6 +262,10 @@ function setTheme(dark) {
|
||||
function toggleTheme() {
|
||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||
}
|
||||
function toggleNavGroup(e, id) {
|
||||
e.preventDefault();
|
||||
document.getElementById(id).classList.toggle('open');
|
||||
}
|
||||
(function() {
|
||||
try {
|
||||
var s = localStorage.getItem('ft-theme');
|
||||
|
||||
@@ -4,35 +4,29 @@
|
||||
{% block breadcrumb %}<a href="/contracts/">行情数据</a> / {{ contract }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if latest %}
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">最新收盘</div>
|
||||
<div class="value">{{ latest.close|int }}</div>
|
||||
<div class="trend">日期 {{ latest.date }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">当日振幅</div>
|
||||
<div class="value">{{ latest.amp_5d|int if latest.amp_5d else '—' }}</div>
|
||||
<div class="trend">日波幅 {{ latest.diff|int }} 点</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">数据条数</div>
|
||||
<div class="value">{{ row_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-title">日线数据</div>
|
||||
<p style="font-size:0.78rem;color:var(--sub);margin-bottom:12px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
||||
<p style="font-size:0.78rem;color:var(--sub);margin:0;">
|
||||
振幅 = 近 5 日 (最高−最低) 均值取整 · 点击振幅值查看计算过程
|
||||
</p>
|
||||
<span style="font-size:0.78rem;color:var(--sub);">共 {{ total_rows }} 条</span>
|
||||
</div>
|
||||
|
||||
{% if page == 1 and next_date %}
|
||||
<div style="background:var(--accent-light);border:1px solid var(--accent);border-radius:8px;padding:12px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;">
|
||||
<span style="font-size:0.78rem;color:var(--sub);">📅 次日预测</span>
|
||||
<span style="font-weight:600;">{{ next_date }} {{ next_weekday }}</span>
|
||||
<span style="color:var(--sub);">预计振幅</span>
|
||||
<span class="amp-cell" style="color:var(--accent);font-weight:700;font-size:1.1rem;cursor:pointer;" data-next="1">{{ next_amp }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="bars-table">
|
||||
<tr><th>日期</th><th>星期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>波幅</th><th>5日均振幅</th></tr>
|
||||
{% for row in rows %}
|
||||
<tr data-index="{{ loop.index0 }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}">
|
||||
<tr data-index="{{ row.global_idx }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}">
|
||||
<td>{{ row.date }}</td>
|
||||
<td>{{ row.weekday }}</td>
|
||||
<td>{{ row.open }}</td>
|
||||
@@ -52,35 +46,194 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:24px;">
|
||||
{% if page > 1 %}
|
||||
<a class="btn" href="?page={{ page - 1 }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 14px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">← 上一页</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span style="font-size:0.82rem;padding:5px 12px;background:var(--accent);color:#fff;border-radius:5px;font-weight:600;">{{ p }}</span>
|
||||
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
|
||||
<a class="btn" href="?page={{ p }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 12px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">{{ p }}</a>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<span style="color:var(--sub);padding:5px 4px;">…</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a class="btn" href="?page={{ page + 1 }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 14px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">下一页 →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pos_dates %}
|
||||
<div class="section-title" style="margin-top:28px;display:flex;align-items:center;gap:12px;">
|
||||
<span>持仓排名 · 前20</span>
|
||||
<input type="date" id="pos-date-select" value="{{ selected_pos_date }}" min="{{ pos_dates[-1] }}" max="{{ pos_dates[0] }}" onchange="changePosDate(this.value)" style="font-size:0.82rem;padding:4px 10px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;">
|
||||
<button onclick="openAnalysisDrawer()" style="margin-left:auto;padding:6px 16px;border:1px solid var(--accent);border-radius:5px;background:var(--accent-light);color:var(--accent);cursor:pointer;font-size:0.82rem;font-weight:600;white-space:nowrap;">AI 分析</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;">
|
||||
|
||||
{% set types = [('volume', '成交量'), ('long', '多单持仓'), ('short', '空单持仓')] %}
|
||||
{% for dtype, dlabel in types %}
|
||||
<div class="table-wrap" style="margin-bottom:0;">
|
||||
<table style="font-size:0.82rem;">
|
||||
<tr><th colspan="4" style="font-size:0.8rem;color:var(--accent);">{{ dlabel }}</th></tr>
|
||||
<tr><th>#</th><th>会员</th><th>持仓</th><th>增减</th></tr>
|
||||
{% for row in pos_data[dtype] %}
|
||||
<tr>
|
||||
<td style="color:var(--sub);">{{ row.rank }}</td>
|
||||
<td style="text-align:left;">{{ row.institution }}</td>
|
||||
<td>{{ row.value }}</td>
|
||||
<td style="color:{% if row.change > 0 %}var(--danger-fg){% elif row.change < 0 %}var(--success-fg){% else %}var(--sub){% endif %};">
|
||||
{% if row.change > 0 %}+{% endif %}{{ row.change }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if dtype != 'volume' %}
|
||||
<tr style="background:var(--th-bg);font-weight:600;">
|
||||
<td></td><td style="text-align:right;">增减合计</td>
|
||||
<td></td>
|
||||
<td style="color:{% if pos_totals[dtype] > 0 %}var(--danger-fg){% elif pos_totals[dtype] < 0 %}var(--success-fg){% else %}var(--sub){% endif %};">
|
||||
{% if pos_totals[dtype] > 0 %}+{% endif %}{{ pos_totals[dtype] }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="analysisOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="closeAnalysisDrawer()"></div>
|
||||
<div id="analysisDrawer" style="display:none;position:fixed;top:0;right:0;width:700px;max-width:95vw;height:100%;background:var(--surface);border-left:1px solid var(--border);z-index:100;box-shadow:-4px 0 24px rgba(0,0,0,.12);transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:1rem;">持仓分析 {{ contract }}</h3>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button onclick="copyPrompt()" style="padding:4px 12px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.78rem;">复制</button>
|
||||
<button onclick="closeAnalysisDrawer()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);line-height:1;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="analysisContent" style="flex:1;margin:0;padding:16px 20px;overflow-y:auto;font-size:0.78rem;line-height:1.6;white-space:pre-wrap;word-break:break-all;color:var(--fg);background:var(--bg);">加载中...</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function changePosDate(d) {
|
||||
var url = new URL(window.location);
|
||||
url.searchParams.set('pos_date', d);
|
||||
window.location = url;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.amp-cell').forEach(function(cell) {
|
||||
cell.addEventListener('click', function() {
|
||||
var row = cell.parentElement.parentElement;
|
||||
var tbl = document.getElementById('bars-table');
|
||||
var rows = tbl.querySelectorAll('tr');
|
||||
var idx = parseInt(row.dataset.index);
|
||||
if (idx < 5) return;
|
||||
// Next-day prediction row
|
||||
if (cell.dataset.next) {
|
||||
var dataRows = document.querySelectorAll('#bars-table tr[data-index]');
|
||||
if (dataRows.length < 5) return;
|
||||
var rows = Array.from(dataRows).slice(0, 5);
|
||||
showDrawer(cell, '{{ contract }}', rows, true);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('drawer-title').textContent = '{{ contract }}';
|
||||
document.getElementById('drawer-date').textContent = '目标: ' + row.dataset.date + ' ' + row.dataset.weekday + ' 振幅 ' + cell.textContent.trim();
|
||||
// Collect 5 older rows via DOM navigation (next siblings in the table)
|
||||
var row = cell.parentElement.parentElement;
|
||||
var olderRows = [];
|
||||
var cursor = row;
|
||||
for (var j = 0; j < 5; j++) {
|
||||
cursor = cursor.nextElementSibling;
|
||||
if (!cursor || !cursor.dataset.index) return;
|
||||
olderRows.push(cursor);
|
||||
}
|
||||
showDrawer(cell, '{{ contract }}', olderRows, false);
|
||||
});
|
||||
});
|
||||
|
||||
function showDrawer(cell, product, calcRows, isNext) {
|
||||
document.getElementById('drawer-title').textContent = product;
|
||||
var label = isNext ? '次日预测' : '目标';
|
||||
document.getElementById('drawer-date').textContent = label + ' 振幅 ' + cell.textContent.trim();
|
||||
|
||||
var html = '<tr><td>日期</td><td>最高−最低</td></tr>';
|
||||
var sum = 0;
|
||||
for (var j = idx - 5; j < idx; j++) {
|
||||
var r = rows[j + 1];
|
||||
calcRows.forEach(function(r) {
|
||||
html += '<tr><td>' + r.dataset.date + ' ' + r.dataset.weekday + '</td><td>' + r.dataset.diff + '</td></tr>';
|
||||
sum += parseInt(r.dataset.diff);
|
||||
}
|
||||
});
|
||||
document.getElementById('drawer-table').innerHTML = html;
|
||||
document.getElementById('drawer-result').innerHTML = '合计 <b>' + sum + '</b> ÷ 5 = <b>' + (sum / 5).toFixed(1) + '</b><br>四舍五入 → <b>' + Math.round(sum / 5) + '</b>';
|
||||
|
||||
document.getElementById('overlay').classList.add('show');
|
||||
document.getElementById('drawer').classList.add('show');
|
||||
|
||||
if (!isNext) {
|
||||
var row = cell.parentElement.parentElement;
|
||||
if (activeRow) activeRow.classList.remove('active');
|
||||
row.classList.add('active');
|
||||
activeRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
var promptText = '';
|
||||
function openAnalysisDrawer() {
|
||||
var overlay = document.getElementById('analysisOverlay');
|
||||
var drawer = document.getElementById('analysisDrawer');
|
||||
drawer.style.display = 'flex';
|
||||
overlay.style.display = 'block';
|
||||
requestAnimationFrame(function() {
|
||||
drawer.style.transform = 'translateX(0)';
|
||||
});
|
||||
var content = document.getElementById('analysisContent');
|
||||
if (promptText) {
|
||||
content.textContent = promptText;
|
||||
return;
|
||||
}
|
||||
content.textContent = '加载中...';
|
||||
fetch('/contracts/{{ contract }}/analysis-prompt')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(t) {
|
||||
promptText = t;
|
||||
content.textContent = t;
|
||||
})
|
||||
.catch(function() { content.textContent = '加载失败'; });
|
||||
}
|
||||
function closeAnalysisDrawer() {
|
||||
document.getElementById('analysisOverlay').style.display = 'none';
|
||||
document.getElementById('analysisDrawer').style.transform = 'translateX(100%)';
|
||||
}
|
||||
function copyPrompt() {
|
||||
navigator.clipboard.writeText(promptText).then(function() {
|
||||
showToast('已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
function showToast(msg) {
|
||||
var m = document.getElementById('toastMask');
|
||||
if (!m) {
|
||||
m = document.createElement('div');
|
||||
m.id = 'toastMask';
|
||||
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:999;display:flex;align-items:center;justify-content:center;transition:opacity .25s;opacity:0;';
|
||||
m.onclick = function() { hideToast(); };
|
||||
var b = document.createElement('div');
|
||||
b.id = 'toastBox';
|
||||
b.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:32px 40px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.18);';
|
||||
m.appendChild(b);
|
||||
document.body.appendChild(m);
|
||||
}
|
||||
var box = document.getElementById('toastBox');
|
||||
box.innerHTML = '<div style="font-size:2rem;margin-bottom:12px;">✅</div><p style="font-size:1rem;font-weight:600;color:var(--fg);">' + msg + '</p>';
|
||||
m.style.display = 'flex';
|
||||
requestAnimationFrame(function() { m.style.opacity = '1'; });
|
||||
clearTimeout(m._tid);
|
||||
m._tid = setTimeout(hideToast, 1800);
|
||||
}
|
||||
function hideToast() {
|
||||
var m = document.getElementById('toastMask');
|
||||
if (!m) return;
|
||||
m.style.opacity = '0';
|
||||
setTimeout(function() { m.style.display = 'none'; }, 250);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,32 +4,35 @@
|
||||
{% block breadcrumb %}<a href="/contracts/">合约总览</a>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">监控合约</div>
|
||||
<div class="value">{{ contracts|length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">数据条数</div>
|
||||
<div class="value">{{ total_bars }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">最新日期</div>
|
||||
<div class="value">{{ latest_date }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">合约列表</div>
|
||||
<div class="tag-row">
|
||||
{% for c in contracts %}
|
||||
<a class="tag" href="/contracts/{{ c }}">{{ c }}</a>
|
||||
{% set active_product = request.query_params.get('product', product_groups[0].code if product_groups else '') %}
|
||||
|
||||
<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; }
|
||||
</style>
|
||||
|
||||
<div class="tabs">
|
||||
{% for pg in product_groups %}
|
||||
<a href="/contracts/?product={{ pg.code }}" class="tab-btn{% if active_product == pg.code %} active{% endif %}">
|
||||
{{ pg.code }} · {{ pg.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for pg in product_groups %}
|
||||
{% if pg.code == active_product %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr><th>合约</th><th>日期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>振幅</th><th>操作</th></tr>
|
||||
{% for c in contracts %}
|
||||
<tr><th>合约</th><th>日期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>振幅</th><th>次日振幅</th><th>操作</th></tr>
|
||||
{% for c in pg.contracts %}
|
||||
<tr>
|
||||
<td><strong>{{ c }}</strong></td>
|
||||
{% if c in contract_bars %}
|
||||
@@ -45,12 +48,22 @@
|
||||
<span class="na">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if c in contract_next_amp %}
|
||||
<span class="badge badge-warn">{{ contract_next_amp[c] }}</span>
|
||||
{% else %}
|
||||
<td colspan="6" class="na">暂无数据</td>
|
||||
<span class="na">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="7" class="na">暂无数据</td>
|
||||
{% endif %}
|
||||
<td><a href="/contracts/{{ c }}" style="color:var(--accent);text-decoration:none;">详情 →</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}数据录入{% endblock %}
|
||||
{% block heading %}数据录入{% endblock %}
|
||||
{% block breadcrumb %}机构持仓录入{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<div class="section-title">持仓快照</div>
|
||||
<form method="post" action="/input">
|
||||
<div class="form-group">
|
||||
<label>机构</label>
|
||||
<select name="institution" required>
|
||||
{% for inst in institutions %}
|
||||
<option value="{{ inst }}">{{ inst }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>方向</label>
|
||||
<select name="direction" required>
|
||||
<option value="long">多单</option>
|
||||
<option value="short">空单</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>日期</label>
|
||||
<input type="date" name="date_str" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>持仓量(手)</label>
|
||||
<input type="number" name="position" required placeholder="例: 193030">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>持仓均价</label>
|
||||
<input type="number" name="avg_cost" step="0.01" required placeholder="例: 995.26">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">提交</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 · 期货量化</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f8fafc; --surface: #fff; --fg: #1e293b; --sub: #64748b;
|
||||
--border: #e2e8f0; --accent: #3b82f6; --danger: #ef4444;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg: #0f172a; --surface: #1e293b; --fg: #e2e8f0; --sub: #94a3b8;
|
||||
--border: #334155; --accent: #60a5fa; --danger: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg); color: var(--fg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-box {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 40px; width: 360px; max-width: 90vw;
|
||||
}
|
||||
.login-box h1 { font-size: 1.3rem; text-align: center; margin-bottom: 8px; }
|
||||
.login-box .sub { color: var(--sub); font-size: 0.85rem; text-align: center; margin-bottom: 28px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--sub); margin-bottom: 5px; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
|
||||
border-radius: 6px; font-size: 0.9rem; background: var(--bg); color: var(--fg);
|
||||
}
|
||||
.form-group input:focus { outline: none; border-color: var(--accent); }
|
||||
.btn {
|
||||
width: 100%; padding: 10px; border: none; border-radius: 6px;
|
||||
font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||
background: var(--accent); color: #fff; margin-top: 8px;
|
||||
}
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.error {
|
||||
background: #fef2f2; color: var(--danger); padding: 10px 14px;
|
||||
border-radius: 6px; font-size: 0.85rem; margin-bottom: 16px; text-align: center;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: fixed; top: 16px; right: 24px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--fg); padding: 6px 14px; border-radius: 6px;
|
||||
cursor: pointer; font-size: 0.82rem; z-index: 10;
|
||||
}
|
||||
.toggle-btn:hover { opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="toggle-btn" id="themeToggle">🌙 暗色模式</button>
|
||||
<div class="login-box">
|
||||
<h1>📊 期货量化系统</h1>
|
||||
<p class="sub">请输入账号密码</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var themeBtn = document.getElementById('themeToggle');
|
||||
themeBtn.addEventListener('click', toggleTheme);
|
||||
function setTheme(dark) {
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
themeBtn.textContent = '☀️ 亮色模式';
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
themeBtn.textContent = '🌙 暗色模式';
|
||||
}
|
||||
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
|
||||
}
|
||||
function toggleTheme() {
|
||||
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
||||
}
|
||||
(function() {
|
||||
try {
|
||||
var s = localStorage.getItem('ft-theme');
|
||||
if (s === 'dark' || (!s && matchMedia('(prefers-color-scheme: dark)').matches)) setTheme(true);
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,415 @@
|
||||
{% 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:70px;">
|
||||
<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:60px;">
|
||||
<label>买卖</label>
|
||||
<select name="direction" required>
|
||||
{% for v, label in option_directions %}
|
||||
<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><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>
|
||||
{% if t.direction == 'sell' %}
|
||||
<span class="badge badge-down">卖</span>
|
||||
{% else %}
|
||||
<span class="badge badge-up">买</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ t.strike_price }}</td>
|
||||
<td>{{ t.open_price }}</td>
|
||||
<td>{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}</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>
|
||||
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
|
||||
onclick="showEditForm('option', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.option_type }}', '{{ t.direction }}', {{ t.strike_price }}, {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', null, null, null, '{{ t.status }}')">编辑</button>
|
||||
<form method="post" action="/options/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
|
||||
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</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><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>
|
||||
{% if t.direction == 'sell' %}
|
||||
<span class="badge badge-down">卖</span>
|
||||
{% else %}
|
||||
<span class="badge badge-up">买</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ t.strike_price }}</td>
|
||||
<td style="font-size:0.8rem;">{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}</td>
|
||||
<td style="font-size:0.8rem;">{{ t.close_date }} {{ weekdays[t.close_date.weekday()] }}</td>
|
||||
<td>{{ (t.close_date - t.open_date).days }}天</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>
|
||||
<span style="color:var(--sub);cursor:pointer;font-size:0.75rem;margin-left:4px;"
|
||||
onclick="showPnlDrawer('{{ t.product_code }} {{ t.contract_code.replace(t.product_code, '', 1) }} {{ t.option_type }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.close_price }}, {{ t.open_fee or 0 }}, {{ t.close_fee or 0 }}, {{ p }})" title="计算过程">ⓘ</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
|
||||
onclick="showEditForm('option', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.option_type }}', '{{ t.direction }}', {{ t.strike_price }}, {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', {{ t.close_price }}, {{ t.close_fee or 0 }}, '{{ t.close_date }}', '{{ t.status }}')">编辑</button>
|
||||
<form method="post" action="/options/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
|
||||
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</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 %}
|
||||
|
||||
<div id="editFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:98;" onclick="hideEditForm()"></div>
|
||||
<div id="editFormBox" 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:99;width:400px;max-width:90vw;max-height:90vh;overflow-y:auto;">
|
||||
<h3 style="margin-bottom:16px;">编辑记录</h3>
|
||||
<form method="post" id="editForm">
|
||||
<div class="form-group">
|
||||
<label>合约</label>
|
||||
<input type="text" name="contract_code" id="editContract" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select name="option_type" id="editOptionType" required>
|
||||
{% for v, label in option_types %}
|
||||
<option value="{{ v }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>买卖</label>
|
||||
<select name="direction" id="editDirection" required>
|
||||
{% for v, label in option_directions %}
|
||||
<option value="{{ v }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>行权价</label>
|
||||
<input type="number" step="any" name="strike_price" id="editStrike" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开仓日期</label>
|
||||
<input type="date" name="open_date" id="editOpenDate" required style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>权利金</label>
|
||||
<input type="number" step="any" name="open_price" id="editOpenPrice" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>手续费</label>
|
||||
<input type="number" step="any" name="open_fee" id="editOpenFee" value="0" style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div id="editCloseFields" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label>平仓日期</label>
|
||||
<input type="date" name="close_date" id="editCloseDate" style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>平仓价格</label>
|
||||
<input type="number" step="any" name="close_price" id="editClosePrice" style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>手续费</label>
|
||||
<input type="number" step="any" name="close_fee" id="editCloseFee" value="0" style="font-size:0.9rem;">
|
||||
</div>
|
||||
</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="hideEditForm()">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="pnlOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hidePnlDrawer()"></div>
|
||||
<div id="pnlBox" 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:380px;max-width:90vw;">
|
||||
<h3 style="margin-bottom:4px;" id="pnlTitle"></h3>
|
||||
<p style="font-size:0.8rem;color:var(--sub);margin-bottom:14px;" id="pnlFormula"></p>
|
||||
<table class="calc-table" style="width:100%;border-collapse:collapse;font-size:0.84rem;margin-bottom:12px;" id="pnlTable"></table>
|
||||
<div style="font-size:1rem;" id="pnlResult"></div>
|
||||
<button style="position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);" onclick="hidePnlDrawer()">×</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showPnlDrawer(title, direction, openPrice, closePrice, openFee, closeFee, result) {
|
||||
document.getElementById('pnlTitle').textContent = title;
|
||||
var dirLabel = direction === 'sell' ? '卖' : '买';
|
||||
document.getElementById('pnlFormula').textContent = '方向: ' + dirLabel + ' | 每点 20 元';
|
||||
var diff = direction === 'sell' ? (openPrice - closePrice) : (closePrice - openPrice);
|
||||
var gross = diff * 20;
|
||||
var fees = openFee + closeFee;
|
||||
var html = '<tr style="background:var(--th-bg);"><td>项目</td><td>计算</td><td style="text-align:right;">金额</td></tr>';
|
||||
html += '<tr><td>价差</td><td>' + (direction === 'sell' ? openPrice + ' - ' + closePrice : closePrice + ' - ' + openPrice) + '</td><td style="text-align:right;">' + diff.toFixed(2) + ' 点</td></tr>';
|
||||
html += '<tr><td>毛利</td><td>' + diff.toFixed(2) + ' × 20</td><td style="text-align:right;">' + gross.toFixed(2) + '</td></tr>';
|
||||
html += '<tr><td>开仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + openFee.toFixed(2) + '</td></tr>';
|
||||
html += '<tr><td>平仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + closeFee.toFixed(2) + '</td></tr>';
|
||||
document.getElementById('pnlTable').innerHTML = html;
|
||||
document.getElementById('pnlResult').innerHTML = '<b>盈亏 = ' + gross.toFixed(2) + ' - ' + openFee.toFixed(2) + ' - ' + closeFee.toFixed(2) + ' = <span style="color:' + (result > 0 ? 'var(--success-fg)' : result < 0 ? 'var(--danger-fg)' : 'var(--sub)') + ';">' + (result > 0 ? '+' : '') + result.toFixed(2) + '</span></b>';
|
||||
document.getElementById('pnlOverlay').style.display = 'block';
|
||||
document.getElementById('pnlBox').style.display = 'block';
|
||||
}
|
||||
function showEditForm(type, id, productCode, contractCode, optionType, direction, strike, openPrice, openFee, openDate, closePrice, closeFee, closeDate, status) {
|
||||
document.getElementById('editForm').action = '/' + type + 's/' + id + '/edit';
|
||||
document.getElementById('editContract').value = contractCode;
|
||||
document.getElementById('editOptionType').value = optionType;
|
||||
document.getElementById('editDirection').value = direction;
|
||||
document.getElementById('editStrike').value = strike;
|
||||
document.getElementById('editOpenDate').value = openDate;
|
||||
document.getElementById('editOpenPrice').value = openPrice;
|
||||
document.getElementById('editOpenFee').value = openFee || 0;
|
||||
if (status === 'closed' && closeDate) {
|
||||
document.getElementById('editCloseFields').style.display = 'block';
|
||||
document.getElementById('editCloseDate').value = closeDate;
|
||||
document.getElementById('editClosePrice').value = closePrice;
|
||||
document.getElementById('editCloseFee').value = closeFee || 0;
|
||||
} else {
|
||||
document.getElementById('editCloseFields').style.display = 'none';
|
||||
}
|
||||
document.getElementById('editFormOverlay').style.display = 'block';
|
||||
document.getElementById('editFormBox').style.display = 'block';
|
||||
}
|
||||
function hideEditForm() {
|
||||
document.getElementById('editFormOverlay').style.display = 'none';
|
||||
document.getElementById('editFormBox').style.display = 'none';
|
||||
}
|
||||
function hidePnlDrawer() {
|
||||
document.getElementById('pnlOverlay').style.display = 'none';
|
||||
document.getElementById('pnlBox').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div id="confirmOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:199;" onclick="hideConfirm()"></div>
|
||||
<div id="confirmBox" 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:28px 32px;z-index:200;text-align:center;max-width:90vw;">
|
||||
<p id="confirmMsg" style="font-size:0.95rem;margin-bottom:20px;"></p>
|
||||
<div style="display:flex;gap:10px;justify-content:center;">
|
||||
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideConfirm()">取消</button>
|
||||
<button class="btn" style="background:var(--danger);color:#fff;" id="confirmBtn">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function confirmDelete(e, msg, url) {
|
||||
e.preventDefault();
|
||||
document.getElementById('confirmMsg').textContent = msg;
|
||||
document.getElementById('confirmBtn').onclick = function() {
|
||||
var f = document.createElement('form');
|
||||
f.method = 'POST';
|
||||
f.action = url;
|
||||
document.body.appendChild(f);
|
||||
f.submit();
|
||||
};
|
||||
document.getElementById('confirmOverlay').style.display = 'block';
|
||||
document.getElementById('confirmBox').style.display = 'block';
|
||||
}
|
||||
function hideConfirm() {
|
||||
document.getElementById('confirmOverlay').style.display = 'none';
|
||||
document.getElementById('confirmBox').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,276 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}对冲管理{% endblock %}
|
||||
{% block heading %}对冲管理{% endblock %}
|
||||
{% block breadcrumb %}振幅锁仓 · 仓位跟踪{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% set error = request.query_params.get('error', '') %}
|
||||
|
||||
{% if error %}
|
||||
<div style="background:var(--danger-bg);color:var(--danger-fg);padding:10px 16px;border-radius:6px;margin-bottom:16px;font-size:0.88rem;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# ═══════════════ 新建轮次 ═══════════════ #}
|
||||
<div class="section-title">新建轮次</div>
|
||||
<div class="form-card" style="margin-bottom:28px;">
|
||||
<form method="post" action="/positions/round">
|
||||
<div style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;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:1;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:1;min-width:100px;">
|
||||
<label>每日手数 N</label>
|
||||
<input type="number" name="daily_hands" value="1" min="1" max="10" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">开始新轮</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not active_rounds %}
|
||||
<div style="text-align:center;padding:60px;color:var(--sub);">
|
||||
<p style="font-size:1.1rem;margin-bottom:8px;">暂无活跃轮次</p>
|
||||
<p style="font-size:0.82rem;">创建一轮新的交易,开始跟踪仓位和锁仓状态</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for round in active_rounds %}
|
||||
{# ═══════════════ 轮次卡片 ═══════════════ #}
|
||||
{% set locks = round.total_locks %}
|
||||
<div style="margin-bottom:32px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
|
||||
|
||||
{# ── 轮次头部 ── #}
|
||||
<div style="display:flex;align-items:center;gap:16px;padding:14px 20px;background:var(--th-bg);flex-wrap:wrap;">
|
||||
<strong style="font-size:1rem;">{{ round.contract_code }}</strong>
|
||||
<span class="badge badge-up">{{ round.daily_hands }} 手/天</span>
|
||||
<span style="font-size:0.82rem;font-weight:600;{% if locks >= round.max_locks %}color:var(--danger-fg);{% endif %}">
|
||||
锁仓 {{ locks }}/{{ round.max_locks }}
|
||||
{% if locks >= round.max_locks %} ⚠ 熔断{% endif %}
|
||||
</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;">
|
||||
<button style="background:var(--surface);color:var(--fg);border:1px solid var(--border);padding:3px 12px;border-radius:4px;cursor:pointer;font-size:0.78rem;"
|
||||
onclick="showMaxLocksModal({{ round.id }}, {{ round.max_locks }})">修改锁仓数</button>
|
||||
<form method="post" action="/positions/{{ round.id }}/close" style="display:inline;">
|
||||
<input type="hidden" name="result" value="profit_taken">
|
||||
<button class="btn" style="background:var(--success);color:#fff;font-size:0.78rem;padding:4px 14px;" onclick="confirmDelete(event, '确认 {{ round.contract_code }} 止盈清仓?', this.closest('form').action)">止盈</button>
|
||||
</form>
|
||||
<form method="post" action="/positions/{{ round.id }}/close" style="display:inline;">
|
||||
<input type="hidden" name="result" value="meltdown">
|
||||
<button class="btn" style="background:var(--danger);color:#fff;font-size:0.78rem;padding:4px 14px;" onclick="confirmDelete(event, '确认 {{ round.contract_code }} 熔断清仓?', this.closest('form').action)">熔断</button>
|
||||
</form>
|
||||
<form method="post" action="/positions/{{ round.id }}/delete" style="display:inline;">
|
||||
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除 {{ round.contract_code }} 整轮数据?', this.closest('form').action)">删除</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 20px;">
|
||||
|
||||
{# ── 止盈阈值 ── #}
|
||||
{% if round.positions %}
|
||||
{% set latest = round.positions[-1] %}
|
||||
<div style="background:var(--accent-light);border:1px solid var(--accent);border-radius:6px;padding:10px 16px;margin-bottom:16px;display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
|
||||
<span style="font-size:0.78rem;color:var(--sub);">止盈阈值</span>
|
||||
<span style="font-weight:700;">{{ round.daily_hands }} × {{ latest.amp_threshold }} = {{ round.daily_hands * latest.amp_threshold }} 点</span>
|
||||
<span style="color:var(--sub);">|</span>
|
||||
<span style="font-size:0.78rem;color:var(--sub);">当前 A = {{ latest.amp_threshold }}</span>
|
||||
<span style="color:var(--sub);">|</span>
|
||||
<span style="font-size:0.78rem;color:var(--sub);">总手数 {{ round.daily_hands * round.positions|length }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── 添加仓位 ── #}
|
||||
<div style="margin-bottom:16px;">
|
||||
<form method="post" action="/positions/{{ round.id }}/add">
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;min-width:100px;">
|
||||
<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:1;min-width:100px;">
|
||||
<label>振幅 A</label>
|
||||
<input type="number" name="amp_threshold" required placeholder="如 18" style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;min-width:70px;">
|
||||
<label>手数</label>
|
||||
<input type="number" name="hands" value="{{ round.daily_hands }}" min="1" max="10" required 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>
|
||||
|
||||
{# ── 仓位表格 ── #}
|
||||
{% if round.positions %}
|
||||
<div class="table-wrap" style="margin-bottom:0;">
|
||||
<table style="font-size:0.84rem;">
|
||||
<tr>
|
||||
<th>开仓价</th><th>A</th><th>锁仓价</th><th>手数</th><th>已锁</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
{% for p in round.positions|reverse %}
|
||||
<tr>
|
||||
<td>{{ p.open_price }}</td>
|
||||
<td>{{ p.amp_threshold }}</td>
|
||||
<td style="color:var(--danger-fg);font-weight:600;">{{ p.lock_price }}</td>
|
||||
<td>{{ p.hands }}</td>
|
||||
<td>
|
||||
{% if p.locked_count > 0 %}
|
||||
<span class="badge badge-warn">{{ p.locked_count }}/{{ p.hands }}</span>
|
||||
{% else %}
|
||||
<span class="na">0/{{ p.hands }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if p.locked_count >= p.hands %}
|
||||
<span class="badge badge-down">已全锁</span>
|
||||
{% elif p.locked_count > 0 %}
|
||||
<span class="badge badge-warn">部分锁</span>
|
||||
{% else %}
|
||||
<span class="badge badge-up">活跃</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:center;">
|
||||
<button style="background:var(--warn-bg);color:var(--warn-fg);border:1px solid var(--warn);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:0.75rem;font-weight:600;"
|
||||
{% if p.locked_count >= p.hands %}disabled style="opacity:0.4;cursor:default;"{% endif %}
|
||||
onclick="showLockConfirm({{ p.id }}, {{ p.lock_price }})">锁</button>
|
||||
<form method="post" action="/positions/{{ p.id }}/unlock" style="display:inline;">
|
||||
<button style="background:var(--surface);color:var(--sub);border:1px solid var(--border);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:0.75rem;"
|
||||
{% if p.locked_count == 0 %}disabled style="opacity:0.4;cursor:default;"{% endif %}>撤</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:24px;color:var(--sub);font-size:0.84rem;">暂无仓位,请添加。</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<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 showMaxLocksModal(rid, currentMax) {
|
||||
document.getElementById('maxLocksRid').value = rid;
|
||||
document.getElementById('maxLocksInput').value = currentMax;
|
||||
document.getElementById('maxLocksModal').style.display = 'block';
|
||||
document.getElementById('maxLocksModalOverlay').style.display = 'block';
|
||||
document.getElementById('maxLocksInput').focus();
|
||||
}
|
||||
function hideMaxLocksModal() {
|
||||
document.getElementById('maxLocksModal').style.display = 'none';
|
||||
document.getElementById('maxLocksModalOverlay').style.display = 'none';
|
||||
}
|
||||
function doUpdateMaxLocks() {
|
||||
var rid = document.getElementById('maxLocksRid').value;
|
||||
var fd = new FormData();
|
||||
fd.append('max_locks', document.getElementById('maxLocksInput').value);
|
||||
fetch('/positions/' + rid + '/max-locks', {method:'POST', body:fd})
|
||||
.then(function() { location.reload(); });
|
||||
}
|
||||
function showLockConfirm(pid, lockPrice) {
|
||||
document.getElementById('lockPid').value = pid;
|
||||
document.getElementById('lockPriceInput').value = lockPrice;
|
||||
document.getElementById('lockModal').style.display = 'block';
|
||||
document.getElementById('lockModalOverlay').style.display = 'block';
|
||||
document.getElementById('lockPriceInput').focus();
|
||||
}
|
||||
function hideLockModal() {
|
||||
document.getElementById('lockModal').style.display = 'none';
|
||||
document.getElementById('lockModalOverlay').style.display = 'none';
|
||||
}
|
||||
function doLock() {
|
||||
var pid = document.getElementById('lockPid').value;
|
||||
var fd = new FormData();
|
||||
fd.append('lock_price', document.getElementById('lockPriceInput').value);
|
||||
fetch('/positions/' + pid + '/lock', {method:'POST', body:fd})
|
||||
.then(function() { location.reload(); });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="maxLocksModalOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:159;" onclick="hideMaxLocksModal()"></div>
|
||||
<div id="maxLocksModal" 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:160;width:300px;max-width:90vw;">
|
||||
<h3 style="margin-bottom:16px;">修改锁仓上限</h3>
|
||||
<input type="hidden" id="maxLocksRid">
|
||||
<div class="form-group">
|
||||
<label>锁仓次数上限(熔断阈值)</label>
|
||||
<input type="number" id="maxLocksInput" min="1" max="10" required style="font-size:1rem;text-align:center;font-weight:600;">
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideMaxLocksModal()">取消</button>
|
||||
<button class="btn" style="background:var(--accent);color:#fff;" onclick="doUpdateMaxLocks()">确认修改</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lockModalOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:149;" onclick="hideLockModal()"></div>
|
||||
<div id="lockModal" 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:150;width:300px;max-width:90vw;">
|
||||
<h3 style="margin-bottom:16px;">确认锁仓</h3>
|
||||
<input type="hidden" id="lockPid">
|
||||
<div class="form-group">
|
||||
<label>锁仓价位</label>
|
||||
<input type="number" id="lockPriceInput" required style="font-size:1rem;text-align:center;font-weight:600;color:var(--danger-fg);">
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideLockModal()">取消</button>
|
||||
<button class="btn" style="background:var(--warn);color:#fff;" onclick="doLock()">确认锁仓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="confirmOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:199;" onclick="hideConfirm()"></div>
|
||||
<div id="confirmBox" 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:28px 32px;z-index:200;text-align:center;max-width:90vw;">
|
||||
<p id="confirmMsg" style="font-size:0.95rem;margin-bottom:20px;"></p>
|
||||
<div style="display:flex;gap:10px;justify-content:center;">
|
||||
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideConfirm()">取消</button>
|
||||
<button class="btn" style="background:var(--danger);color:#fff;" id="confirmBtn">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function confirmDelete(e, msg, url) {
|
||||
e.preventDefault();
|
||||
document.getElementById('confirmMsg').textContent = msg;
|
||||
document.getElementById('confirmBtn').onclick = function() {
|
||||
var f = document.createElement('form');
|
||||
f.method = 'POST';
|
||||
f.action = url;
|
||||
document.body.appendChild(f);
|
||||
f.submit();
|
||||
};
|
||||
document.getElementById('confirmOverlay').style.display = 'block';
|
||||
document.getElementById('confirmBox').style.display = 'block';
|
||||
}
|
||||
function hideConfirm() {
|
||||
document.getElementById('confirmOverlay').style.display = 'none';
|
||||
document.getElementById('confirmBox').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,404 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}交易记录{% endblock %}
|
||||
{% block heading %}交易记录{% endblock %}
|
||||
{% block breadcrumb %}开平仓记录{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% set view = request.query_params.get('view', '') %}
|
||||
{% set product_tab = request.query_params.get('tab', '全部') %}
|
||||
{% set product_codes = product_contracts.keys()|list %}
|
||||
{% set grouped_open = {} %}
|
||||
{% for t in open_trades %}
|
||||
{% set _ = grouped_open.setdefault(t.product_code, []).append(t) %}
|
||||
{% endfor %}
|
||||
|
||||
<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; }
|
||||
.sub-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.sub-tab-btn {
|
||||
padding: 4px 14px; border: 1px solid var(--border); background: var(--surface);
|
||||
font-size: 0.78rem; color: var(--sub); cursor: pointer; text-decoration: none;
|
||||
border-radius: 4px; transition: color .12s, border-color .12s;
|
||||
}
|
||||
.sub-tab-btn:hover { color: var(--fg); border-color: var(--accent); }
|
||||
.sub-tab-btn.active { color: var(--accent); border-color: var(--accent); font-weight: 600; background: var(--accent-light); }
|
||||
</style>
|
||||
|
||||
<div class="tabs">
|
||||
<a href="/trades/" class="tab-btn{% if view != 'closed' %} active{% endif %}">📋 持仓</a>
|
||||
<a href="/trades/?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="/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 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: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" step="any" 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" 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>
|
||||
|
||||
<div class="sub-tabs">
|
||||
<a href="/trades/?tab=全部" class="sub-tab-btn{% if product_tab == '全部' %} active{% endif %}">全部</a>
|
||||
{% for pc in product_codes %}
|
||||
{% set count = grouped_open.get(pc, [])|length %}
|
||||
<a href="/trades/?tab={{ pc }}" class="sub-tab-btn{% if product_tab == pc %} active{% endif %}">{{ pc }}{% if count %} · {{ count }}{% endif %}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if open_trades %}
|
||||
{% for pc in product_codes %}
|
||||
{% if product_tab == '全部' or product_tab == pc %}
|
||||
{% set trades = grouped_open.get(pc, []) %}
|
||||
{% if trades %}
|
||||
<div class="table-wrap" style="margin-bottom:24px;">
|
||||
{% if product_tab == '全部' %}
|
||||
<div class="section-title" style="font-size:0.88rem;margin-bottom:0;padding:6px 12px;">{{ pc }} · {{ trades|length }} 笔</div>
|
||||
{% endif %}
|
||||
<table>
|
||||
<tr><th>合约</th><th>方向</th><th>开仓日期</th><th>开仓价</th><th>手续费</th><th>操作</th></tr>
|
||||
{% for t in trades %}
|
||||
<tr>
|
||||
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</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 }} {{ weekdays[t.open_date.weekday()] }}</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>
|
||||
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
|
||||
onclick="showEditForm('trade', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', null, null, null, '{{ t.status }}')">编辑</button>
|
||||
<form method="post" action="/trades/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
|
||||
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% 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.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 }} {{ weekdays[t.open_date.weekday()] }}</td>
|
||||
<td style="font-size:0.8rem;">{{ t.close_date }} {{ weekdays[t.close_date.weekday()] }}</td>
|
||||
<td>{{ (t.close_date - t.open_date).days }}天</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(--danger-fg);{% elif p < 0 %}color:var(--success-fg);{% else %}color:var(--sub);{% endif %}">
|
||||
{% if p > 0 %}+{% endif %}{{ p }}
|
||||
</span>
|
||||
<span style="color:var(--sub);cursor:pointer;font-size:0.75rem;margin-left:4px;"
|
||||
onclick="showPnlDrawer('{{ t.product_code }} {{ t.contract_code.replace(t.product_code, '', 1) }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.close_price }}, {{ t.open_fee or 0 }}, {{ t.close_fee or 0 }}, {{ p }})" title="计算过程">ⓘ</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
|
||||
onclick="showEditForm('trade', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', {{ t.close_price }}, {{ t.close_fee or 0 }}, '{{ t.close_date }}', '{{ t.status }}')">编辑</button>
|
||||
<form method="post" action="/trades/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
|
||||
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</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(--danger-fg);{% elif ns.total_pnl < 0 %}color:var(--success-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 = '/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>
|
||||
{% endif %}
|
||||
|
||||
<div id="editFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:98;" onclick="hideEditForm()"></div>
|
||||
<div id="editFormBox" 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:99;width:400px;max-width:90vw;max-height:90vh;overflow-y:auto;">
|
||||
<h3 style="margin-bottom:16px;">编辑记录</h3>
|
||||
<form method="post" id="editForm">
|
||||
<div class="form-group">
|
||||
<label>合约</label>
|
||||
<input type="text" name="contract_code" id="editContract" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>方向</label>
|
||||
<select name="direction" id="editDirection" required>
|
||||
{% for v, label in directions %}
|
||||
<option value="{{ v }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开仓日期</label>
|
||||
<input type="date" name="open_date" id="editOpenDate" required style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开仓价</label>
|
||||
<input type="number" step="any" name="open_price" id="editOpenPrice" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开仓手续费</label>
|
||||
<input type="number" step="any" name="open_fee" id="editOpenFee" value="0" style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div id="editCloseFields" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label>平仓日期</label>
|
||||
<input type="date" name="close_date" id="editCloseDate" style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>平仓价格</label>
|
||||
<input type="number" step="any" name="close_price" id="editClosePrice" style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>平仓手续费</label>
|
||||
<input type="number" step="any" name="close_fee" id="editCloseFee" value="0" style="font-size:0.9rem;">
|
||||
</div>
|
||||
</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="hideEditForm()">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="pnlOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hidePnlDrawer()"></div>
|
||||
<div id="pnlBox" 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:380px;max-width:90vw;">
|
||||
<h3 style="margin-bottom:4px;" id="pnlTitle"></h3>
|
||||
<p style="font-size:0.8rem;color:var(--sub);margin-bottom:14px;" id="pnlFormula"></p>
|
||||
<table class="calc-table" style="width:100%;border-collapse:collapse;font-size:0.84rem;margin-bottom:12px;" id="pnlTable"></table>
|
||||
<div style="font-size:1rem;" id="pnlResult"></div>
|
||||
<button style="position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);" onclick="hidePnlDrawer()">×</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showPnlDrawer(title, direction, openPrice, closePrice, openFee, closeFee, result) {
|
||||
document.getElementById('pnlTitle').textContent = title;
|
||||
var dirLabel = direction === 'short' ? '空' : '多';
|
||||
document.getElementById('pnlFormula').textContent = '方向: ' + dirLabel + ' | 每点 20 元';
|
||||
var diff = direction === 'short' ? (openPrice - closePrice) : (closePrice - openPrice);
|
||||
var gross = diff * 20;
|
||||
var fees = openFee + closeFee;
|
||||
var html = '<tr style="background:var(--th-bg);"><td>项目</td><td>计算</td><td style="text-align:right;">金额</td></tr>';
|
||||
html += '<tr><td>价差</td><td>' + (direction === 'short' ? openPrice + ' - ' + closePrice : closePrice + ' - ' + openPrice) + '</td><td style="text-align:right;">' + diff.toFixed(2) + ' 点</td></tr>';
|
||||
html += '<tr><td>毛利</td><td>' + diff.toFixed(2) + ' × 20</td><td style="text-align:right;">' + gross.toFixed(2) + '</td></tr>';
|
||||
html += '<tr><td>开仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + openFee.toFixed(2) + '</td></tr>';
|
||||
html += '<tr><td>平仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + closeFee.toFixed(2) + '</td></tr>';
|
||||
document.getElementById('pnlTable').innerHTML = html;
|
||||
document.getElementById('pnlResult').innerHTML = '<b>盈亏 = ' + gross.toFixed(2) + ' - ' + openFee.toFixed(2) + ' - ' + closeFee.toFixed(2) + ' = <span style="color:' + (result > 0 ? 'var(--danger-fg)' : result < 0 ? 'var(--success-fg)' : 'var(--sub)') + ';">' + (result > 0 ? '+' : '') + result.toFixed(2) + '</span></b>';
|
||||
document.getElementById('pnlOverlay').style.display = 'block';
|
||||
document.getElementById('pnlBox').style.display = 'block';
|
||||
}
|
||||
function hidePnlDrawer() {
|
||||
document.getElementById('pnlOverlay').style.display = 'none';
|
||||
document.getElementById('pnlBox').style.display = 'none';
|
||||
}
|
||||
function showEditForm(type, id, productCode, contractCode, direction, openPrice, openFee, openDate, closePrice, closeFee, closeDate, status) {
|
||||
document.getElementById('editForm').action = '/' + type + 's/' + id + '/edit';
|
||||
document.getElementById('editContract').value = contractCode;
|
||||
document.getElementById('editDirection').value = direction;
|
||||
document.getElementById('editOpenDate').value = openDate;
|
||||
document.getElementById('editOpenPrice').value = openPrice;
|
||||
document.getElementById('editOpenFee').value = openFee || 0;
|
||||
if (status === 'closed' && closeDate) {
|
||||
document.getElementById('editCloseFields').style.display = 'block';
|
||||
document.getElementById('editCloseDate').value = closeDate;
|
||||
document.getElementById('editClosePrice').value = closePrice;
|
||||
document.getElementById('editCloseFee').value = closeFee || 0;
|
||||
} else {
|
||||
document.getElementById('editCloseFields').style.display = 'none';
|
||||
}
|
||||
document.getElementById('editFormOverlay').style.display = 'block';
|
||||
document.getElementById('editFormBox').style.display = 'block';
|
||||
}
|
||||
function hideEditForm() {
|
||||
document.getElementById('editFormOverlay').style.display = 'none';
|
||||
document.getElementById('editFormBox').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div id="confirmOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:199;" onclick="hideConfirm()"></div>
|
||||
<div id="confirmBox" 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:28px 32px;z-index:200;text-align:center;max-width:90vw;">
|
||||
<p id="confirmMsg" style="font-size:0.95rem;margin-bottom:20px;"></p>
|
||||
<div style="display:flex;gap:10px;justify-content:center;">
|
||||
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideConfirm()">取消</button>
|
||||
<button class="btn" style="background:var(--danger);color:#fff;" id="confirmBtn">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function confirmDelete(e, msg, url) {
|
||||
e.preventDefault();
|
||||
document.getElementById('confirmMsg').textContent = msg;
|
||||
document.getElementById('confirmBtn').onclick = function() {
|
||||
var f = document.createElement('form');
|
||||
f.method = 'POST';
|
||||
f.action = url;
|
||||
document.body.appendChild(f);
|
||||
f.submit();
|
||||
};
|
||||
document.getElementById('confirmOverlay').style.display = 'block';
|
||||
document.getElementById('confirmBox').style.display = 'block';
|
||||
}
|
||||
function hideConfirm() {
|
||||
document.getElementById('confirmOverlay').style.display = 'none';
|
||||
document.getElementById('confirmBox').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Binary file not shown.
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: ft-app
|
||||
container_name: app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user