Repository navigation
Expand file tree
/
Copy pathmodels.py
More file actions
374 lines (311 loc) · 14.5 KB
/
Copy pathmodels.py
File metadata and controls
374 lines (311 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.db.models import Sum, F, Q
from django.core.exceptions import ValidationError
from decimal import Decimal
from django.utils import timezone
import uuid
# ==========================================
# 0. Global Constants
# ==========================================
ZERO = Decimal('0.0000')
# ==========================================
# 1. User Model
# ==========================================
class User(AbstractUser):
gender = models.CharField(max_length=20, blank=True, null=True)
account_balance = models.DecimalField(
max_digits=20,
decimal_places=4,
default=Decimal('100000.0000')
)
# ==========================================
# 2. Market Reference Data
# ==========================================
class Industry(models.Model):
"""
Industry Model
"""
name = models.CharField(max_length=100, unique=True)
sector = models.CharField(max_length=100, blank=True, null=True) # 对应你看到的 Sector: Technology
description = models.TextField(blank=True, null=True)
def __str__(self):
return self.name
class Company(models.Model):
symbol = models.CharField(max_length=10, primary_key=True)
full_name = models.CharField(max_length=255)
industry = models.ForeignKey(
Industry,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='companies'
)
market_cap = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True)
trailing_pe = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
price_sales = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
current_price = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
def __str__(self):
return self.symbol
class Financials(models.Model):
symbol = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='financials')
report_date = models.DateField()
total_revenue = models.BigIntegerField(null=True, blank=True)
gross_profit = models.BigIntegerField(null=True, blank=True)
operating_income = models.BigIntegerField(null=True, blank=True)
net_income = models.BigIntegerField(null=True, blank=True)
basic_eps = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
total_assets = models.BigIntegerField(null=True, blank=True)
total_liabilities = models.BigIntegerField(null=True, blank=True)
current_assets = models.BigIntegerField(null=True, blank=True)
current_liabilities = models.BigIntegerField(null=True, blank=True)
inventory = models.BigIntegerField(null=True, blank=True, default=0)
@property
def current_ratio(self):
"""Liquidity: Current Assets / Current Liabilities"""
if self.current_liabilities and self.current_liabilities != 0:
return round(self.current_assets / self.current_liabilities, 2)
return 0
@property
def quick_ratio(self):
"""Liquidity: (Current Assets - Inventory) / Current Liabilities"""
if self.current_liabilities and self.current_liabilities != 0:
inv = self.inventory if self.inventory else 0
return round((self.current_assets - inv) / self.current_liabilities, 2)
return 0
@property
def debt_asset_ratio(self):
"""Leverage: (Total Liabilities / Total Assets) * 100"""
if self.total_assets and self.total_assets != 0:
return round((self.total_liabilities / self.total_assets) * 100, 2)
return 0
@property
def net_margin(self):
"""Profitability: (Net Income / Total Revenue) * 100"""
if self.total_revenue and self.total_revenue != 0:
return round((self.net_income / self.total_revenue) * 100, 2)
return 0
class DailyPrice(models.Model):
symbol = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='daily_prices')
financial_id = models.ForeignKey(Financials, on_delete=models.SET_NULL, null=True, blank=True)
trade_date = models.DateField()
open_price = models.DecimalField(max_digits=12, decimal_places=4)
high_price = models.DecimalField(max_digits=12, decimal_places=4)
low_price = models.DecimalField(max_digits=12, decimal_places=4)
close_price = models.DecimalField(max_digits=12, decimal_places=4)
volume = models.BigIntegerField()
class Meta:
indexes = [
models.Index(fields=['symbol', '-trade_date']),
models.Index(fields=['trade_date']),
]
class GlobalSimulationState(models.Model):
"""
Global control table to synchronize all users to the same virtual timeline.
"""
current_global_date = models.DateField(default=timezone.now)
is_market_open = models.BooleanField(default=True)
last_step_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Global Simulation State"
verbose_name_plural = "Global Simulation State"
def __str__(self):
return f"Global Virtual Date: {self.current_global_date}"
# ==========================================
# 3. Simulation & Portfolio
# ==========================================
class Simulation(models.Model):
class Status(models.TextChoices):
ACTIVE = 'ACTIVE', 'Active'
CLOSED = 'CLOSED', 'Closed'
class Mode(models.TextChoices):
LIVE = 'LIVE', 'Live Multiplayer' # Now implies the shared exchange mode
BACKTEST = 'BACKTEST', 'Private Backtest'
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='simulations')
name = models.CharField(max_length=100)
start_date = models.DateField()
# This now syncs with GlobalSimulationState.current_global_date for LIVE mode
current_virtual_date = models.DateField(default=timezone.now)
initial_cash = models.DecimalField(max_digits=20, decimal_places=4)
available_cash = models.DecimalField(max_digits=20, decimal_places=4, default=ZERO)
status = models.CharField(max_length=10, choices=Status.choices, default=Status.ACTIVE)
mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.LIVE)
created_at = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
"""
Overrides the save method to automate ledger initialization.
When a new simulation is created, we set available_cash = initial_cash
and create an initial cash flow record for audit purposes.
"""
# 1. Check if this is a new instance creation
is_new = self._state.adding
if is_new:
# 2. Sync financial fields with initial_cash on creation
self.available_cash = self.initial_cash
# 3. Save the Simulation instance first to get an ID
super().save(*args, **kwargs)
# 4. Automatically record the initial deposit in the Cash Flow ledger
if is_new:
# Local import to prevent circular dependency
from .models import Simulation_Cash_Flow
Simulation_Cash_Flow.objects.create(
sim=self,
change_type=Simulation_Cash_Flow.FlowType.INIT,
before_balance=Decimal('0.0000'),
amount=self.initial_cash,
after_balance=self.initial_cash,
# Unique request_id for auditing purposes
request_id=f"AUTO_INIT_{self.id}_{int(timezone.now().timestamp())}"
)
@property
def total_fees(self):
"""
Aggregates all recorded transaction fees from the cash flow ledger.
This helps reconcile the gap between Floating PnL and NAV.
"""
from .models import Simulation_Cash_Flow
# Summing all negative amounts marked as 'FEE'
result = self.cash_flows.filter(change_type=Simulation_Cash_Flow.FlowType.FEE).aggregate(models.Sum('amount'))['amount__sum']
return abs(result) if result else Decimal('0.0000')
@property
def total_realized_pnl(self):
result = self.transactions.aggregate(models.Sum('realized_pnl'))['realized_pnl__sum']
return result if result else Decimal('0.0000')
@property
def market_value(self):
from .models import Simulation_Holding, DailyPrice
total = ZERO
holdings = self.holdings.all()
for h in holdings:
price_rec = DailyPrice.objects.filter(
symbol=h.symbol,
trade_date__lte=self.current_virtual_date
).order_by('-trade_date').first()
if price_rec:
total += h.quantity * price_rec.close_price
return total
class Meta:
constraints = [
models.CheckConstraint(
condition=Q(available_cash__gte=0),
name='simulation_cash_non_negative_v2'
)
]
class TradeOrder(models.Model):
"""
Order Book for peer-to-peer trading.
"""
class OrderSide(models.TextChoices):
BUY = 'BUY', 'Buy'
SELL = 'SELL', 'Sell'
class OrderStatus(models.TextChoices):
PENDING = 'PENDING', 'Pending'
PARTIAL = 'PARTIAL', 'Partially Filled'
FILLED = 'FILLED', 'Filled'
CANCELLED = 'CANCELLED', 'Cancelled'
EXPIRED = 'EXPIRED', 'Expired'
user = models.ForeignKey(User, on_delete=models.CASCADE)
sim = models.ForeignKey(Simulation, on_delete=models.CASCADE, related_name='orders')
symbol = models.ForeignKey(Company, on_delete=models.CASCADE)
side = models.CharField(max_length=10, choices=OrderSide.choices)
price = models.DecimalField(max_digits=12, decimal_places=4)
quantity = models.IntegerField()
filled_quantity = models.IntegerField(default=0)
status = models.CharField(max_length=10, choices=OrderStatus.choices, default=OrderStatus.FILLED)
order_date = models.DateField() # The virtual date when the order was placed
created_at = models.DateTimeField(auto_now_add=True)
avg_cost_snapshot = models.DecimalField(
max_digits=18,
decimal_places=4,
null=True,
blank=True,
default=0
)
class Meta:
indexes = [
models.Index(fields=['symbol', 'status', 'side', 'price']),
]
# ==========================================
# 4. Trading & Auditing
# ==========================================
class Simulation_Transaction(models.Model):
voucher_no = models.CharField(
max_length=64,
db_index=True,
null=True,
blank=True,
verbose_name="凭证编号"
)
digital_signature = models.CharField(
max_length=100,
null=True,
blank=True
)
class TransType(models.TextChoices):
BUY = 'BUY', 'Buy'
SELL = 'SELL', 'Sell'
sim = models.ForeignKey(Simulation, on_delete=models.CASCADE, related_name='transactions')
symbol = models.ForeignKey(Company, on_delete=models.CASCADE)
daily_price = models.ForeignKey(DailyPrice, on_delete=models.SET_NULL, null=True)
trade_date = models.DateField()
type = models.CharField(max_length=4, choices=TransType.choices)
quantity = models.IntegerField()
price = models.DecimalField(max_digits=12, decimal_places=4)
total_amount = models.DecimalField(max_digits=20, decimal_places=4)
created_at = models.DateTimeField(auto_now_add=True)
matched_order = models.ForeignKey(TradeOrder, on_delete=models.SET_NULL, null=True, blank=True)
opponent_order = models.ForeignKey(TradeOrder, on_delete=models.SET_NULL, null=True, blank=True,related_name='counterpart_transactions')
realized_pnl = models.DecimalField(max_digits=18, decimal_places=4, default=0)
fees = models.DecimalField(max_digits=12, decimal_places=4, default=Decimal('0.0000'))
def save(self, *args, **kwargs):
if not self.voucher_no:
count = Simulation_Transaction.objects.filter(sim=self.sim).count()
order_number = count + 1
self.voucher_no = f"TX-{self.sim.id}-{order_number:04d}"
if not self.digital_signature:
self.digital_signature = uuid.uuid4().hex
super().save(*args, **kwargs)
def __str__(self):
return f"{self.voucher_no} - {self.symbol_id} ({self.type})"
class Simulation_Cash_Flow(models.Model):
class FlowType(models.TextChoices):
BUY = 'BUY', 'Purchase'
SELL = 'SELL', 'Liquidation'
FEE = 'FEE', 'Commission'
INIT = 'INIT', 'Initial Deposit'
sim = models.ForeignKey(Simulation, on_delete=models.CASCADE, related_name='cash_flows')
change_type = models.CharField(max_length=10, choices=FlowType.choices)
before_balance = models.DecimalField(max_digits=20, decimal_places=4)
amount = models.DecimalField(max_digits=20, decimal_places=4) # Negative for Buy/Fee
after_balance = models.DecimalField(max_digits=20, decimal_places=4)
transaction = models.ForeignKey(Simulation_Transaction, on_delete=models.SET_NULL, null=True, blank=True)
request_id = models.CharField(max_length=64, unique=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
def clean(self):
if abs((self.before_balance + self.amount) - self.after_balance) > Decimal('0.0001'):
raise ValidationError("Cash flow audit failed: balance mismatch.")
# ==========================================
# 5. Inventory & Snapshots
# ==========================================
class Simulation_Holding(models.Model):
sim = models.ForeignKey(Simulation, on_delete=models.CASCADE, related_name='holdings')
symbol = models.ForeignKey(Company, on_delete=models.CASCADE)
quantity = models.IntegerField()
avg_cost = models.DecimalField(max_digits=20, decimal_places=4)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['sim', 'symbol'], name='unique_holding_per_sim')
]
indexes = [
models.Index(fields=['sim', 'symbol']),
]
class Simulation_NAV_History(models.Model):
sim = models.ForeignKey(Simulation, on_delete=models.CASCADE, related_name='nav_history')
record_date = models.DateField()
nav = models.DecimalField(max_digits=20, decimal_places=4)
cash = models.DecimalField(max_digits=20, decimal_places=4)
market_value = models.DecimalField(max_digits=20, decimal_places=4)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.sim.name} - {self.record_date}"