Repository navigation
Expand file tree
/
Copy pathviews.py
More file actions
1571 lines (1294 loc) · 60.1 KB
/
Copy pathviews.py
File metadata and controls
1571 lines (1294 loc) · 60.1 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
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from django.shortcuts import render, HttpResponseRedirect
from django.urls import reverse
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Q, F, Sum
from django.utils import timezone
from .models import Company
import json
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from .models import *
from django.db.models import OuterRef, Subquery, DecimalField
from django.db import transaction
from django.db.models import DecimalField, FloatField, IntegerField
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render, HttpResponseRedirect, get_object_or_404
from .models import Simulation_Cash_Flow
import ast
import operator
import matplotlib.pyplot as plt
from matplotlib import font_manager
import os
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase.ttfonts import TTFont
from django.http import HttpResponse
import io
import uuid
FONT_PATH = r"C:\Users\24300\Desktop\Stock\fonts\msyh.ttc"
if os.path.exists(FONT_PATH):
font_prop = font_manager.FontProperties(fname=FONT_PATH)
plt.rcParams['font.sans-serif'] = [font_prop.get_name()]
plt.rcParams['axes.unicode_minus'] = False
# ==========================================
# GLOBAL SETTINGS & PRECISION CONSTANTS
# ==========================================
ZERO = Decimal('0.0000')
SEARCH_LIMIT = 20
INITIAL_BALANCE = Decimal('100000.00')
PRECISION_4 = Decimal('0.0001')
PRECISION_2 = Decimal('0.01')
COMMISSION_RATE = Decimal('0.0003') # 0.03% Transaction Fee
def safe_eval_formula(formula_str, context):
"""
Safely evaluates a mathematical formula string using Abstract Syntax Trees (AST).
Args:
formula_str (str): The raw string from the user (e.g., "net_income / total_revenue").
context (dict): A dictionary mapping variable names to their numeric values
(e.g., {'net_income': 100, 'total_revenue': 500}).
Returns:
Decimal: The result of the calculation.
Raises:
Exception: If the formula contains illegal operations or undefined variables.
"""
# Define allowed operators for calculation
# This acts as a whitelist to prevent execution of malicious code
allowed_operators = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg # Supports negative numbers like -5
}
def eval_node(node):
# Case 1: The node is a literal number (e.g., 100 or 0.05)
if isinstance(node, ast.Num):
return Decimal(str(node.n))
# Case 2: The node is a binary operation (e.g., a + b, a / b)
elif isinstance(node, ast.BinOp):
left_val = eval_node(node.left)
right_val = eval_node(node.right)
op_type = type(node.op)
if op_type in allowed_operators:
# Case 2.1: Robust Division handling
if op_type == ast.Div:
# Use a small epsilon to prevent division by zero or near-zero values
# This avoids extreme spikes in the chart caused by tiny denominators
if abs(right_val) < Decimal('0.000001'):
return Decimal('0')
return left_val / right_val
return allowed_operators[op_type](left_val, right_val)
raise ValueError(f"Operator {op_type.__name__} is not allowed.")
# Case 3: The node is a unary operation (e.g., -income)
elif isinstance(node, ast.UnaryOp):
operand_val = eval_node(node.operand)
op_type = type(node.op)
if op_type in allowed_operators:
return allowed_operators[op_type](operand_val)
raise ValueError(f"Unary operator {op_type.__name__} is not allowed.")
# Case 4: The node is a variable name (e.g., total_revenue)
elif isinstance(node, ast.Name):
# Fetch the value from the provided financial context
val = context.get(node.id)
if val is None:
raise NameError(f"Variable '{node.id}' is not found in financial data.")
return Decimal(str(val))
else:
raise TypeError(f"Unsupported syntax: {type(node).__name__}")
try:
# Clean the string and parse it into an expression tree
# mode='eval' ensures we only process a single expression, not a script
cleaned_formula = formula_str.replace(" ", "")
tree = ast.parse(cleaned_formula, mode='eval')
return eval_node(tree.body)
except Exception as e:
# Catch and re-raise with a clear message for the frontend
raise Exception(f"Formula Error: {str(e)}")
def quantize_4(value):
return Decimal(value).quantize(PRECISION_4, rounding=ROUND_HALF_UP)
# ==========================================
# 1. ROBUST MARKET DATA ENGINE
# ==========================================
def get_market_price(symbol_obj, target_date):
"""
Finds the most recent closing price on or before target_date.
Handles weekends, holidays, and market suspensions.
"""
try:
price_record = DailyPrice.objects.filter(
symbol=symbol_obj,
trade_date__lt=target_date
).latest('trade_date')
return price_record
except DailyPrice.DoesNotExist:
return None
def calculate_nav_optimized(sim, target_date):
"""
Production-grade NAV calculation optimized for RDBMS.
Formula: Available Cash + Frozen Buying Cash + Current Market Value
This implementation replaces Python loops with Database Aggregations.
"""
# --- 1. Subquery: Get the latest close price for each symbol before target_date ---
# This prevents loading thousands of historical price rows into memory.
latest_price_subquery = DailyPrice.objects.filter(
symbol=OuterRef('symbol'),
trade_date__lt=target_date
).order_by('-trade_date').values('close_price')[:1]
# --- 2. Calculate Frozen Cash (Unfilled Buy Orders) ---
# Summing (price * remaining_qty) directly in the database.
# We ignore COMMISSION_RATE here for simplicity, or add it if strictly required.
frozen_data = TradeOrder.objects.filter(
sim=sim,
side='BUY',
status__in=['PENDING', 'PARTIAL']
).aggregate(
total_frozen=Sum(
F('price') * (F('quantity') - F('filled_quantity')),
output_field=DecimalField()
)
)
frozen_cash = frozen_data['total_frozen'] or Decimal('0.0000')
# --- 3. Calculate Market Value (Existing Holdings) ---
# Annotate each holding with the latest price from subquery, then aggregate.
holding_data = sim.holdings.exclude(quantity=0).annotate(
latest_price=Subquery(latest_price_subquery, output_field=DecimalField())
).aggregate(
total_mkt_val=Sum(
F('quantity') * F('latest_price'),
output_field=DecimalField()
)
)
holdings_market_value = holding_data['total_mkt_val'] or Decimal('0.0000')
# --- 4. Calculate Market Value (Locked in Sell Orders) ---
# Shares locked in sell orders are still assets owned by the user.
sell_order_data = TradeOrder.objects.filter(
sim=sim,
side='SELL',
status__in=['PENDING', 'PARTIAL']
).annotate(
latest_price=Subquery(latest_price_subquery, output_field=DecimalField())
).aggregate(
frozen_mkt_val=Sum(
(F('quantity') - F('filled_quantity')) * F('latest_price'),
output_field=DecimalField()
)
)
frozen_shares_market_value = sell_order_data['frozen_mkt_val'] or Decimal('0.0000')
# --- 5. Final Aggregation ---
total_market_value = holdings_market_value + frozen_shares_market_value
total_nav = sim.available_cash + frozen_cash + total_market_value
# Returns exactly what the frontend needs
return total_nav, total_market_value
# Helper to ensure 4 decimal places for financial calculations
def quantize_4(val):
return val.quantize(Decimal('0.0001'))
# ==========================================
# 2. AUTHENTICATION & IDENTITY (ER: Stock_User)
# ==========================================
def register_view(request):
"""
Handles user registration and initializes a default trading simulation.
Synchronizes the new user's virtual clock with the Global Master Clock.
"""
if request.method == "POST":
d = request.POST
if d['password'] != d['confirmation']:
return render(request, "stock/register.html", {"message": "Passwords mismatch."})
try:
with transaction.atomic():
# 1. Fetch Global Simulation State to sync the start date
global_state = GlobalSimulationState.objects.select_for_update().first()
if not global_state:
# Emergency initialization if global state is missing
initial_date = datetime.strptime("2026-03-02", "%Y-%m-%d").date()
global_state = GlobalSimulationState.objects.create(
current_global_date=initial_date,
is_market_open=True
)
# The "Time Machine" entry point for this user
shared_virtual_date = global_state.current_global_date
# 2. Create the User profile
user = User.objects.create_user(d['username'], d['email'], d['password'])
user.first_name = d.get('firstname', '')
user.last_name = d.get('lastname', '')
user.gender = d.get('gender', 'Other')
user.account_balance = INITIAL_BALANCE
user.save()
# 3. Create the Simulation instance synced to Global Clock
new_sim = Simulation.objects.create(
user=user,
name=f"Standard Alpha Strategy - {user.username}",
start_date=shared_virtual_date,
current_virtual_date=shared_virtual_date,
initial_cash=INITIAL_BALANCE,
available_cash=INITIAL_BALANCE,
)
# 4. Create the initial NAV history entry for the shared start date
Simulation_NAV_History.objects.create(
sim=new_sim,
record_date=shared_virtual_date,
nav=INITIAL_BALANCE,
cash=INITIAL_BALANCE,
market_value=Decimal('0.00')
)
login(request, user)
return HttpResponseRedirect(reverse("index"))
except Exception as e:
# It's better to log the exception here for debugging
return render(request, "stock/register.html", {"message": str(e)})
return render(request, "stock/register.html")
import traceback
from django.http import HttpResponse
@csrf_exempt
def login_view(request):
print("\n====== LOGIN DEBUG START ======")
try:
print("Method:", request.method)
print("POST:", dict(request.POST))
print("COOKIES:", request.COOKIES)
if request.method == "POST":
u = request.POST.get("username")
p = request.POST.get("password")
print("username:", u)
print("password:", p)
if not u or not p:
return HttpResponse("❌ username or password is None")
user = authenticate(request, username=u, password=p)
print("authenticate result:", user)
if user is not None:
login(request, user)
print("login success")
return HttpResponseRedirect(reverse("index"))
else:
print("authenticate failed")
return HttpResponse("AUTH FAILED")
return render(request, "stock/login.html")
except Exception as e:
print("EXCEPTION:")
traceback.print_exc()
return HttpResponse(f"SERVER ERROR:\n{str(e)}")
@login_required
def logout_view(request):
logout(request)
return HttpResponseRedirect(reverse("login"))
# ==========================================
# 3. MARKET EXPLORER (ER: Company, Financials)
# ==========================================
@login_required
def index(request):
"""
Dashboard view for the multiplayer trading simulation.
Synchronizes all users to a shared virtual timeline via GlobalSimulationState.
"""
# 1. Fetch or Initialize Global Simulation State (The Master Clock)
global_state = GlobalSimulationState.objects.first()
if not global_state:
return HttpResponse("系统错误:请联系管理员初始化全局时钟。")
virtual_today = global_state.current_global_date
if not global_state:
# Emergency fallback if no state exists in DB
initial_date = datetime.strptime("2026-03-02", "%Y-%m-%d").date()
global_state = GlobalSimulationState.objects.create(
current_global_date=initial_date,
is_market_open=True
)
virtual_today = global_state.current_global_date
# 2. Fetch the primary simulation account for the current user
user_sims = Simulation.objects.filter(user=request.user).order_by('-created_at')
active_sim = user_sims.first()
# 3. Lazy Initialization: Create a simulation if none exists
if not active_sim:
active_sim = Simulation.objects.create(
user=request.user,
name=f"Alpha Strategy - {request.user.username}",
start_date=virtual_today,
current_virtual_date=virtual_today, # Sync with global clock
initial_cash=INITIAL_BALANCE,
available_cash=INITIAL_BALANCE,
)
# Create the initial record for the performance chart
Simulation_NAV_History.objects.create(
sim=active_sim,
record_date=virtual_today,
nav=INITIAL_BALANCE,
cash=INITIAL_BALANCE,
market_value=Decimal('0.00')
)
# 4. Market Explorer Logic: Fetch industries and filter companies
all_industries = Industry.objects.all()
selected_industry_id = request.GET.get('industry')
query = request.GET.get('q', '').strip()
# Start with all companies
companies_qs = Company.objects.all()
# Apply Industry filter if selected
if selected_industry_id:
companies_qs = companies_qs.filter(industry_id=selected_industry_id)
# Apply Search query if exists
if query:
companies_qs = companies_qs.filter(
Q(symbol__icontains=query) | Q(full_name__icontains=query)
)
# Slice to top 10 and process prices based on virtual timeline
popular_companies = companies_qs[:10]
processed_stocks = []
for comp in popular_companies:
price_rec = DailyPrice.objects.filter(
symbol=comp,
trade_date__lt=virtual_today
).order_by('-trade_date').first()
if price_rec:
processed_stocks.append({
"symbol": comp.symbol,
"name": comp.full_name,
"price": price_rec.close_price,
})
# 5. Portfolio Accounting: Calculate NAV and Current Holdings
# This now uses the global virtual_today for consistent valuation
total_nav, total_mkt_val = calculate_nav_optimized(active_sim, virtual_today)
raw_holdings = Simulation_Holding.objects.filter(sim=active_sim).exclude(quantity=0).select_related('symbol')
processed_holdings = []
for h in raw_holdings:
price_rec = get_market_price(h.symbol, virtual_today)
exec_price = price_rec.close_price if price_rec else h.symbol.current_price
processed_holdings.append({
"symbol": h.symbol,
"quantity": h.quantity,
"avg_cost": h.avg_cost,
"current_price": exec_price,
"market_value": quantize_4(h.quantity * exec_price),
"calc_pnl": quantize_4((exec_price - h.avg_cost) * h.quantity)
})
# 6. Data Visualization: Prepare labels and datasets for Chart.js
nav_history_qs = Simulation_NAV_History.objects.filter(sim=active_sim,record_date__gte="2026-02-12",record_date__lte=virtual_today).order_by('record_date')
chart_labels = [record.record_date.strftime("%m-%d") for record in nav_history_qs]
chart_data = [float(record.nav) for record in nav_history_qs]
recent_transactions = Simulation_Transaction.objects.filter(sim=active_sim).order_by('-trade_date', '-created_at')[:5]
# 7. Render Response with full context
context = {
"sim": active_sim,
"holdings": processed_holdings,
"popular_stocks": processed_stocks,
"industries": all_industries,
"current_industry": selected_industry_id,
"total_nav": total_nav,
"total_profit": total_nav - active_sim.initial_cash,
"profit_rate": round(((total_nav - active_sim.initial_cash) / active_sim.initial_cash * 100), 2),
"chart_labels_json": json.dumps(chart_labels),
"chart_data_json": json.dumps(chart_data),
"virtual_today": virtual_today, # Passed from global state
"is_market_open": global_state.is_market_open,
"recent_transactions": recent_transactions,
}
return render(request, "stock/index.html", context)
@login_required
def current_sim(request):
sim = Simulation.objects.filter(
user=request.user,
status='ACTIVE'
).order_by('-created_at').first()
if not sim:
return JsonResponse({"error": "No active simulation"}, status=404)
return JsonResponse({
"sim_id": sim.id
})
def api_search_companies(request):
query = request.GET.get('q', '').strip()
if len(query) < 1:
return JsonResponse({'results': []})
# Use select_related to join Industry table and reduce DB hits
companies = Company.objects.filter(
Q(symbol__icontains=query) | Q(full_name__icontains=query)
).select_related('industry')[:6]
results = []
for c in companies:
results.append({
'symbol': c.symbol,
'full_name': c.full_name,
'industry': c.industry.name if c.industry else "N/A",
})
return JsonResponse({'results': results})
def api_search(request):
"""
Fast search API synchronized with the GLOBAL virtual simulation date.
Ensures all users see the same market prices regardless of their individual sim state.
"""
term = request.GET.get('q', '').strip().upper()
if len(term) < 1:
return JsonResponse([], safe=False)
# 1. Get the GLOBAL simulation date (The Master Clock)
global_state = GlobalSimulationState.objects.first()
if global_state:
reference_date = global_state.current_global_date
else:
# Fallback to a safe date if global state isn't initialized
return JsonResponse({"error": "Global state missing"}, status=500)
# 2. Find matching companies
matches = Company.objects.filter(
Q(symbol__icontains=term) | Q(full_name__icontains=term)
)[:SEARCH_LIMIT]
results = []
for m in matches:
# 3. Fetch historical price based on the GLOBAL reference date
price_rec = DailyPrice.objects.filter(
symbol=m,
trade_date__lt=reference_date
).order_by('-trade_date').first()
# If no price exists for this company at this point in time, skip it
if not price_rec:
continue
results.append({
"symbol": m.symbol,
"name": m.full_name,
"price": str(price_rec.close_price),
"pe": str(m.trailing_pe)
})
return JsonResponse({"results": results})
# ==========================================
# 4. TRADING CORE (ER: Transactions, Holdings)
# ==========================================
@csrf_exempt
@login_required
def process_transaction(request):
"""
Refactored Transaction Handler for Peer-to-Peer Trading.
Directly deducts assets (freezing) and creates a PENDING TradeOrder.
"""
if request.method != "POST":
return JsonResponse({"error": "POST required"}, status=405)
try:
# 1. Parse payload
if request.content_type == 'application/json':
payload = json.loads(request.body)
else:
payload = request.POST
sim_id = payload.get('sim_id')
symbol = payload.get('symbol')
qty = int(payload.get('quantity', 0))
side = payload.get('type', payload.get('side', '')).upper()
order_price = Decimal(payload.get('price', '0.0000'))
request_id = payload.get('request_id')
# --- REPLACED START: Enhanced parameter validation for debugging ---
error_fields = []
if not sim_id: error_fields.append("sim_id")
if side not in ['BUY', 'SELL']: error_fields.append(f"side (current: {side})")
if qty <= 0: error_fields.append(f"quantity (current: {qty})")
if order_price <= 0: error_fields.append(f"price (current: {order_price})")
if error_fields:
return JsonResponse({
"success": False,
"error": f"Invalid parameters or price. Check fields: {', '.join(error_fields)}",
"debug_payload": {
"sim_id": sim_id,
"symbol": symbol,
"qty": qty,
"side": side,
"price": str(order_price)
}
})
# --- REPLACED END ---
with transaction.atomic():
# 2. Check Global Market State
global_state = GlobalSimulationState.objects.select_for_update().first()
if not global_state or not global_state.is_market_open:
return JsonResponse({"success": False, "error": "Market is currently closed."})
virtual_today = global_state.current_global_date
# 3. Lock Simulation record
sim = Simulation.objects.select_for_update().filter(
id=sim_id,
user=request.user,
status='ACTIVE'
).first()
if not sim:
return JsonResponse({"success": False, "error": "Active simulation not found."})
# 4. Idempotency Check
if request_id and TradeOrder.objects.filter(id=request_id).exists():
return JsonResponse({"success": True, "message": "Order already placed."})
company = Company.objects.get(symbol=symbol)
# 5. God's Price Boundary Validation (Refined for P2P Visibility)
# Use TODAY'S real market boundaries to ensure the order is physically possible.
today_price_rec = DailyPrice.objects.filter(
symbol=company,
trade_date=virtual_today # Use the actual simulation date
).first()
if not today_price_rec:
return JsonResponse({"success": False, "error": "MARKET_DATA_MISSING"})
# Rejection logic: The price must be within [Low, High]
if not (today_price_rec.low_price <= order_price <= today_price_rec.high_price):
return JsonResponse({
"success": False,
"error": "UNTRADABLE_PRICE",
"message": f"Price {order_price} out of range [{today_price_rec.low_price} - {today_price_rec.high_price}]"
})
# 6. Asset Freezing (Deduction)
subtotal = order_price * qty
estimated_fee = quantize_4(subtotal * COMMISSION_RATE)
avg_cost_at_order = ZERO
if side == "BUY":
total_required = subtotal + estimated_fee
if sim.available_cash < total_required:
return JsonResponse({"success": False, "error": "Insufficient cash."})
sim.available_cash -= total_required
sim.save()
# Sync Holding: Update quantity and recalculate average cost
holding, created = Simulation_Holding.objects.get_or_create(
sim=sim, symbol=company,
defaults={'quantity': 0, 'avg_cost': ZERO}
)
total_cost = (holding.quantity * holding.avg_cost) + subtotal + estimated_fee
holding.quantity += qty
holding.avg_cost = (total_cost / holding.quantity).quantize(Decimal('0.0001'))
holding.save()
elif side == "SELL":
holding = Simulation_Holding.objects.filter(sim=sim, symbol=company).first()
if not holding or holding.quantity < qty:
return JsonResponse({"success": False, "error": "Insufficient shares."})
avg_cost_at_order = holding.avg_cost
# Instant Liquidation: Add net proceeds to cash
net_proceeds = subtotal - estimated_fee
sim.available_cash += net_proceeds
sim.save()
holding.quantity -= qty
if holding.quantity == 0:
holding.delete()
else:
holding.save()
# 7. Create the FILLED Order
new_order = TradeOrder.objects.create(
user=request.user,
sim=sim,
symbol=company,
side=side,
price=order_price,
quantity=qty,
filled_quantity=qty,
status=TradeOrder.OrderStatus.FILLED,
order_date=virtual_today,
avg_cost_snapshot=avg_cost_at_order if side == "SELL" else ZERO
)
# 8. Log Cash Flow (Audit Trail for Entropy/Fees)
# ------------------------------------------------------
# First, determine the total cash impact to calculate before_balance correctly
cash_impact = -total_required if side == 'BUY' else net_proceeds
initial_balance_before_trade = sim.available_cash - cash_impact
# A. Record the Trade Principal (The "Mass" of the trade)
Simulation_Cash_Flow.objects.create(
sim=sim,
request_id=f"TRADE_{new_order.id}",
change_type=side, # 'BUY' or 'SELL'
before_balance=initial_balance_before_trade,
amount=-subtotal if side == 'BUY' else subtotal,
after_balance=initial_balance_before_trade + (-subtotal if side == 'BUY' else subtotal)
)
# B. Record the Fee Separately (This is the "Entropy" you are looking for)
# This specific entry is what your views.py queries to show total_fees_sum
current_temp_balance = initial_balance_before_trade + (-subtotal if side == 'BUY' else subtotal)
Simulation_Cash_Flow.objects.create(
sim=sim,
request_id=f"FEE_{new_order.id}",
change_type='FEE', # Must match the query in your views.py
before_balance=current_temp_balance,
amount=-estimated_fee, # Fees always decrease the system's available cash
after_balance=sim.available_cash
)
# --- 9. Record Audit Trail for "Operation Logs" UI ---
# This ensures the trade appears in transactions_view immediately.
Simulation_Transaction.objects.create(
sim=sim,
symbol=company,
daily_price=today_price_rec, # Use the record found in Step 5
trade_date=virtual_today,
type=side, # 'BUY' or 'SELL'
quantity=qty,
price=order_price,
fees=estimated_fee,
total_amount=subtotal + estimated_fee if side == 'BUY' else subtotal - estimated_fee,
matched_order=new_order,
realized_pnl=ZERO if side == 'BUY' else (order_price - avg_cost_at_order) * qty - estimated_fee
)
return JsonResponse({
"success": True,
"message": f"Successfully executed {side} for {symbol}.",
"order_id": new_order.id,
"status": "FILLED" # Let the frontend know it can update the portfolio immediately
})
except Company.DoesNotExist:
return JsonResponse({"success": False, "error": "Company not found."}, status=404)
except Exception as e:
print(f"Error: {str(e)}")
return JsonResponse({"success": False, "error": "Order failed."}, status=500)
@csrf_exempt
@login_required
def cancel_order(request):
"""
Cancels a PENDING or PARTIAL trade order and releases REMAINING frozen assets.
"""
if request.method != "POST":
return JsonResponse({"error": "POST required"}, status=405)
try:
payload = json.loads(request.body) if request.content_type == 'application/json' else request.POST
order_id = payload.get('order_id')
if not order_id:
return JsonResponse({"success": False, "error": "Order ID is required."})
with transaction.atomic():
# 1. Lock the order and verify ownership.
# Crucial: Allow cancellation for both PENDING and PARTIAL.
order = TradeOrder.objects.select_for_update().filter(
id=order_id,
user=request.user,
status__in=[TradeOrder.OrderStatus.PENDING, TradeOrder.OrderStatus.PARTIAL]
).first()
if not order:
return JsonResponse({"success": False, "error": "Order not found or cannot be cancelled."})
sim = order.sim
# Calculate what is left to be returned
remaining_qty = order.quantity - order.filled_quantity
if remaining_qty <= 0:
return JsonResponse({"success": False, "error": "Order is already fully filled."})
# 2. Asset Release Logic (Only for the remaining portion)
if order.side == 'BUY':
# Refund only the part that hasn't been spent
subtotal = order.price * remaining_qty
estimated_fee = quantize_4(subtotal * COMMISSION_RATE)
refund_amount = subtotal + estimated_fee
sim.available_cash += refund_amount
sim.save()
elif order.side == 'SELL':
# Return only the remaining shares to the Simulation_Holding
holding, created = Simulation_Holding.objects.get_or_create(
sim=sim,
symbol=order.symbol,
defaults={'quantity': 0, 'avg_cost': order.price}
)
holding.quantity += remaining_qty
holding.save()
# 3. Update Order Status
order.status = TradeOrder.OrderStatus.CANCELLED
order.save()
return JsonResponse({
"success": True,
"message": f"Order {order_id} cancelled. {remaining_qty} shares released.",
"available_cash": str(sim.available_cash)
})
except Exception as e:
return JsonResponse({"success": False, "error": str(e)}, status=500)
def internal_matching_engine(execution_date):
"""
Simplified Engine:
Since process_transaction handles instant execution,
this now only serves as a safety cleanup for the day.
"""
# Auto-cancel any lingering non-filled orders from previous days
stale_orders = TradeOrder.objects.filter(
status__in=[TradeOrder.OrderStatus.PENDING, TradeOrder.OrderStatus.PARTIAL],
order_date__lt=execution_date
)
count = stale_orders.count()
stale_orders.update(status=TradeOrder.OrderStatus.CANCELLED)
# Return count to maintain compatibility with existing return type
return count
def execute_settlement(b_order, s_order, qty, price, trade_date, price_rec):
"""
Handles assets transfer. Supports both P2P (b_order & s_order)
and P2M (one of the orders is None).
"""
with transaction.atomic():
subtotal = price * qty
# Using global constants and quantization
buy_fee = quantize_4(subtotal * COMMISSION_RATE)
sell_fee = quantize_4(subtotal * COMMISSION_RATE)
# ==========================================
# A & C. Buyer Side Logic (Only if b_order exists)
# ==========================================
if b_order:
# A. Update Buyer's Portfolio (Only if not already processed)
if b_order.status != TradeOrder.OrderStatus.FILLED:
b_holding, created = Simulation_Holding.objects.get_or_create(
sim=b_order.sim,
symbol=b_order.symbol,
defaults={'quantity': 0, 'avg_cost': ZERO}
)
total_cost = (b_holding.quantity * b_holding.avg_cost) + subtotal
b_holding.quantity += qty
b_holding.avg_cost = quantize_4(total_cost / b_holding.quantity)
b_holding.save()
# C. Buyer Refund Logic (Only if not already processed)
if b_order.status != TradeOrder.OrderStatus.FILLED:
# Frozen: (limit_price * qty) + fee. Actual: (exec_price * qty) + fee.
frozen_unit_price = b_order.price + quantize_4(b_order.price * COMMISSION_RATE)
actual_unit_price = price + quantize_4(price * COMMISSION_RATE)
refund = (frozen_unit_price - actual_unit_price) * qty
if refund > 0:
b_order.sim.available_cash += refund
b_order.sim.save()
# D1. Create Buyer Transaction Record
Simulation_Transaction.objects.create(
sim=b_order.sim,
symbol=b_order.symbol,
daily_price=price_rec,
trade_date=trade_date,
type='BUY',
quantity=qty,
price=price,
total_amount=subtotal + buy_fee,
fees=buy_fee,
voucher_no=f"B{uuid.uuid4().hex[:12].upper()}",
matched_order=b_order,
opponent_order=s_order,
realized_pnl=ZERO
)
#cash_before_fee = b_order.sim.available_cash
#b_order.sim.available_cash -= buy_fee
#b_order.sim.save()
# Cash Flow Ledger
#Simulation_Cash_Flow.objects.create(
#sim=b_order.sim,
#change_type='FEE',
# before_balance=cash_before_fee,
# amount=-buy_fee,
# after_balance=b_order.sim.available_cash,
# request_id=f"FEE_B_{b_order.id}_{int(timezone.now().timestamp())}"
#)
# ==========================================
# Seller Side Logic (Only if s_order exists)
# ==========================================
if s_order:
# Update Seller's Cash (Only if not already processed)
if s_order.status != TradeOrder.OrderStatus.FILLED:
s_order.sim.available_cash += (subtotal - sell_fee)
s_order.sim.save()
cost_at_order_time = s_order.avg_cost_snapshot or ZERO
# Realized PnL = (Current Execution Price - Original Cost) * Quantity - Fee
realized_pnl = (price - cost_at_order_time) * qty - sell_fee
print("DEBUG SELL >>>", price, cost_at_order_time, qty, realized_pnl)
# D2. Create Seller Transaction Record
Simulation_Transaction.objects.create(
sim=s_order.sim,
symbol=s_order.symbol,
daily_price=price_rec,
trade_date=trade_date,
type='SELL',
quantity=qty,
price=price,
total_amount=subtotal - sell_fee,
fees=sell_fee,
voucher_no=f"S{uuid.uuid4().hex[:12].upper()}",
matched_order=s_order,
opponent_order=b_order,
realized_pnl=realized_pnl
)
# D2-Fee. Record Seller's Commission (Only if not already processed)
if s_order.status != TradeOrder.OrderStatus.FILLED:
Simulation_Cash_Flow.objects.create(
sim=s_order.sim,
change_type='FEE',
before_balance=s_order.sim.available_cash + sell_fee,
amount=-sell_fee,
after_balance=s_order.sim.available_cash,
request_id=f"FEE_S_{s_order.id}_{int(timezone.now().timestamp())}"
)
# ==========================================
# E. Update Orders Status (Support Partial Fills)
# ==========================================
for order in [b_order, s_order]:
if order: # Skip if the order is None (Market side in P2M)
order.filled_quantity += qty
if order.filled_quantity >= order.quantity:
order.status = TradeOrder.OrderStatus.FILLED
else:
order.status = TradeOrder.OrderStatus.PARTIAL
order.save()
def is_superuser(user):
return user.is_authenticated and user.is_superuser
@user_passes_test(is_superuser)
def advance_simulation_date(request, sim_id=None):
"""
Global System Clock Controller.
Advances the GlobalSimulationState and triggers the P2P matching engine.
"""
from datetime import timedelta
from django.db import transaction
with transaction.atomic():
# 1. Fetch and Lock the Global State
global_state = GlobalSimulationState.objects.select_for_update().first()
if not global_state:
return JsonResponse({"success": False, "error": "Global state not initialized."})
current_date = global_state.current_global_date
target_next_date = current_date + timedelta(days=1)
# 2. Find the next valid trading date (skip weekends/holidays)
next_market_record = DailyPrice.objects.filter(
trade_date__gte=target_next_date
).order_by('trade_date').first()
if not next_market_record:
# If no more data in DB, we cannot advance
return JsonResponse({"success": False, "error": "End of historical data reached."})
new_date = next_market_record.trade_date
# 3. TRIGGER MATCHING ENGINE (Crucial Step)
# We match orders based on the NEW date's market boundaries.
# This simulates the market opening and processing the order queue.
matches_executed = internal_matching_engine(new_date)
# 4. Update Global State
global_state.current_global_date = new_date