seawolf2357 commited on
Commit
db06ad2
·
verified ·
1 Parent(s): bdd4c81

Upload 8 files

Browse files
battle_arena.py ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiosqlite
2
+ import random
3
+ from datetime import datetime, timedelta
4
+ from typing import Dict, List, Tuple, Optional
5
+
6
+ async def init_battle_arena_db(db_path: str):
7
+ """Initialize Battle Arena tables (prevent DB locks)"""
8
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
9
+ await db.execute("PRAGMA journal_mode=WAL")
10
+ await db.execute("PRAGMA busy_timeout=30000") # 30 second timeout
11
+
12
+ await db.execute("""
13
+ CREATE TABLE IF NOT EXISTS battle_rooms (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ creator_agent_id TEXT,
16
+ creator_email TEXT,
17
+ title TEXT NOT NULL,
18
+ option_a TEXT NOT NULL,
19
+ option_b TEXT NOT NULL,
20
+ battle_type TEXT DEFAULT 'opinion',
21
+ duration_hours INTEGER DEFAULT 24,
22
+ end_time TIMESTAMP NOT NULL,
23
+ total_pool INTEGER DEFAULT 0,
24
+ option_a_pool INTEGER DEFAULT 0,
25
+ option_b_pool INTEGER DEFAULT 0,
26
+ status TEXT DEFAULT 'active',
27
+ winner TEXT,
28
+ resolved_at TIMESTAMP,
29
+ admin_result TEXT,
30
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
31
+ FOREIGN KEY (creator_agent_id) REFERENCES npc_agents(agent_id),
32
+ FOREIGN KEY (creator_email) REFERENCES user_profiles(email)
33
+ )
34
+ """)
35
+
36
+ await db.execute("""
37
+ CREATE TABLE IF NOT EXISTS battle_bets (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ room_id INTEGER NOT NULL,
40
+ bettor_agent_id TEXT,
41
+ bettor_email TEXT,
42
+ choice TEXT NOT NULL,
43
+ bet_amount INTEGER NOT NULL,
44
+ payout INTEGER DEFAULT 0,
45
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+ FOREIGN KEY (room_id) REFERENCES battle_rooms(id),
47
+ FOREIGN KEY (bettor_agent_id) REFERENCES npc_agents(agent_id),
48
+ FOREIGN KEY (bettor_email) REFERENCES user_profiles(email)
49
+ )
50
+ """)
51
+
52
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_battle_rooms_status ON battle_rooms(status)")
53
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_battle_bets_room ON battle_bets(room_id)")
54
+ await db.commit()
55
+
56
+ async def create_battle_room(
57
+ db_path: str,
58
+ creator_id: str,
59
+ is_npc: bool,
60
+ title: str,
61
+ option_a: str,
62
+ option_b: str,
63
+ duration_hours: int = 24,
64
+ battle_type: str = 'opinion'
65
+ ) -> Tuple[bool, str, Optional[int]]:
66
+ """Create battle room (costs 50 GPU)
67
+
68
+ battle_type:
69
+ - 'opinion': Majority vote (subjective opinion, NPC only)
70
+ - 'prediction': Real outcome (objective prediction, users only)
71
+
72
+ duration_hours: 1 hour ~ 365 days (8760 hours)
73
+ """
74
+ if not title or len(title) < 10:
75
+ return False, "❌ Title must be 10+ characters", None
76
+ if not option_a or not option_b:
77
+ return False, "❌ Options A/B required", None
78
+ if duration_hours < 1 or duration_hours > 8760:
79
+ return False, "❌ Duration: 1 hour ~ 365 days", None
80
+ if is_npc and battle_type != 'opinion':
81
+ return False, "❌ NPCs can only create opinion battles", None
82
+
83
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
84
+ await db.execute("PRAGMA busy_timeout=30000")
85
+
86
+ if is_npc:
87
+ cursor = await db.execute(
88
+ "SELECT gpu_dollars FROM npc_agents WHERE agent_id=?", (creator_id,)
89
+ )
90
+ else:
91
+ cursor = await db.execute(
92
+ "SELECT gpu_dollars FROM user_profiles WHERE email=?", (creator_id,)
93
+ )
94
+
95
+ row = await cursor.fetchone()
96
+ if not row or row[0] < 50:
97
+ return False, "❌ Insufficient GPU (50 required)", None
98
+
99
+ end_time = datetime.now() + timedelta(hours=duration_hours)
100
+
101
+ if is_npc:
102
+ await db.execute(
103
+ """INSERT INTO battle_rooms
104
+ (creator_agent_id, title, option_a, option_b, battle_type, duration_hours, end_time)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
106
+ (creator_id, title, option_a, option_b, battle_type, duration_hours, end_time.isoformat())
107
+ )
108
+ await db.execute(
109
+ "UPDATE npc_agents SET gpu_dollars=gpu_dollars-50 WHERE agent_id=?",
110
+ (creator_id,)
111
+ )
112
+ else:
113
+ await db.execute(
114
+ """INSERT INTO battle_rooms
115
+ (creator_email, title, option_a, option_b, battle_type, duration_hours, end_time)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
117
+ (creator_id, title, option_a, option_b, battle_type, duration_hours, end_time.isoformat())
118
+ )
119
+ await db.execute(
120
+ "UPDATE user_profiles SET gpu_dollars=gpu_dollars-50 WHERE email=?",
121
+ (creator_id,)
122
+ )
123
+
124
+ await db.commit()
125
+
126
+ cursor = await db.execute("SELECT last_insert_rowid()")
127
+ room_id = (await cursor.fetchone())[0]
128
+
129
+ type_emoji = '💭' if battle_type == 'opinion' else '🔮'
130
+
131
+ if duration_hours >= 24:
132
+ days = duration_hours // 24
133
+ hours = duration_hours % 24
134
+ if hours > 0:
135
+ duration_str = f"{days} days {hours} hours"
136
+ else:
137
+ duration_str = f"{days} days"
138
+ else:
139
+ duration_str = f"{duration_hours} hours"
140
+
141
+ return True, f"✅ {type_emoji} Battle created! (ID: {room_id}, Duration: {duration_str})", room_id
142
+
143
+ async def place_bet(
144
+ db_path: str,
145
+ room_id: int,
146
+ bettor_id: str,
147
+ is_npc: bool,
148
+ choice: str,
149
+ bet_amount: int
150
+ ) -> Tuple[bool, str]:
151
+ """Execute bet (1-100 GPU random bet)"""
152
+ if choice not in ['A', 'B']:
153
+ return False, "❌ Choose A or B"
154
+ if bet_amount < 1 or bet_amount > 100:
155
+ return False, "❌ Bet 1-100 GPU"
156
+
157
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
158
+ await db.execute("PRAGMA busy_timeout=30000")
159
+ db.row_factory = aiosqlite.Row
160
+
161
+ # Check battle room
162
+ cursor = await db.execute(
163
+ "SELECT * FROM battle_rooms WHERE id=? AND status='active'",
164
+ (room_id,)
165
+ )
166
+ room = await cursor.fetchone()
167
+ if not room:
168
+ return False, "❌ Room not found or closed"
169
+
170
+ room = dict(room)
171
+ end_time = datetime.fromisoformat(room['end_time'])
172
+ if datetime.now() >= end_time:
173
+ return False, "❌ Betting closed"
174
+
175
+ # Check duplicate bet
176
+ if is_npc:
177
+ cursor = await db.execute(
178
+ "SELECT id FROM battle_bets WHERE room_id=? AND bettor_agent_id=?",
179
+ (room_id, bettor_id)
180
+ )
181
+ else:
182
+ cursor = await db.execute(
183
+ "SELECT id FROM battle_bets WHERE room_id=? AND bettor_email=?",
184
+ (room_id, bettor_id)
185
+ )
186
+ existing_bet = await cursor.fetchone()
187
+ if existing_bet:
188
+ return False, "❌ Already bet"
189
+
190
+ # Check and deduct GPU
191
+ if is_npc:
192
+ cursor = await db.execute(
193
+ "SELECT gpu_dollars FROM npc_agents WHERE agent_id=?",
194
+ (bettor_id,)
195
+ )
196
+ user_row = await cursor.fetchone()
197
+ if not user_row or user_row[0] < bet_amount:
198
+ return False, "❌ Insufficient GPU"
199
+ await db.execute(
200
+ "UPDATE npc_agents SET gpu_dollars=gpu_dollars-? WHERE agent_id=?",
201
+ (bet_amount, bettor_id)
202
+ )
203
+ else:
204
+ cursor = await db.execute(
205
+ "SELECT gpu_dollars FROM user_profiles WHERE email=?",
206
+ (bettor_id,)
207
+ )
208
+ user_row = await cursor.fetchone()
209
+ if not user_row:
210
+ return False, f"❌ User not found ({bettor_id})"
211
+ if user_row[0] < bet_amount:
212
+ return False, f"❌ Insufficient GPU (보유: {user_row[0]}, 필요: {bet_amount})"
213
+ await db.execute(
214
+ "UPDATE user_profiles SET gpu_dollars=gpu_dollars-? WHERE email=?",
215
+ (bet_amount, bettor_id)
216
+ )
217
+
218
+ # Record bet
219
+ if is_npc:
220
+ await db.execute(
221
+ """INSERT INTO battle_bets
222
+ (room_id, bettor_agent_id, choice, bet_amount)
223
+ VALUES (?, ?, ?, ?)""",
224
+ (room_id, bettor_id, choice, bet_amount)
225
+ )
226
+ else:
227
+ await db.execute(
228
+ """INSERT INTO battle_bets
229
+ (room_id, bettor_email, choice, bet_amount)
230
+ VALUES (?, ?, ?, ?)""",
231
+ (room_id, bettor_id, choice, bet_amount)
232
+ )
233
+
234
+ # Update battle pool
235
+ if choice == 'A':
236
+ await db.execute(
237
+ """UPDATE battle_rooms
238
+ SET total_pool=total_pool+?, option_a_pool=option_a_pool+?
239
+ WHERE id=?""",
240
+ (bet_amount, bet_amount, room_id)
241
+ )
242
+ else:
243
+ await db.execute(
244
+ """UPDATE battle_rooms
245
+ SET total_pool=total_pool+?, option_b_pool=option_b_pool+?
246
+ WHERE id=?""",
247
+ (bet_amount, bet_amount, room_id)
248
+ )
249
+
250
+ await db.commit()
251
+ return True, f"✅ {choice} 베팅 완료! ({bet_amount} GPU)"
252
+
253
+ async def set_battle_result(
254
+ db_path: str,
255
+ room_id: int,
256
+ admin_email: str,
257
+ winner: str # 'A' or 'B' or 'draw'
258
+ ) -> Tuple[bool, str]:
259
+ """Admin sets actual result for prediction battle
260
+
261
+ Args:
262
+ db_path: Database path
263
+ room_id: Battle room ID
264
+ admin_email: Admin email (for verification)
265
+ winner: 'A', 'B', 'draw' 중 하나
266
+
267
+ Returns:
268
+ (success status, message)
269
+ """
270
+ if winner not in ['A', 'B', 'draw']:
271
+ return False, "❌ winner는 'A', 'B', 'draw' 중 하나여야 함"
272
+
273
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
274
+ await db.execute("PRAGMA busy_timeout=30000")
275
+ db.row_factory = aiosqlite.Row
276
+
277
+ cursor = await db.execute(
278
+ "SELECT * FROM battle_rooms WHERE id=? AND status='active'",
279
+ (room_id,)
280
+ )
281
+ room = await cursor.fetchone()
282
+ if not room:
283
+ return False, "❌ Active battle not found"
284
+
285
+ room = dict(room)
286
+
287
+ # Only prediction type allows admin result setting
288
+ if room['battle_type'] != 'prediction':
289
+ return False, "❌ Opinion battles are auto-judged"
290
+
291
+ # Save result
292
+ await db.execute(
293
+ "UPDATE battle_rooms SET admin_result=? WHERE id=?",
294
+ (winner, room_id)
295
+ )
296
+ await db.commit()
297
+
298
+ # If before deadline, save result and wait
299
+ end_time = datetime.fromisoformat(room['end_time'])
300
+ if datetime.now() < end_time:
301
+ option_name = room['option_a'] if winner == 'A' else room['option_b'] if winner == 'B' else 'Draw'
302
+ remaining = end_time - datetime.now()
303
+ if remaining.days > 0:
304
+ time_str = f"{remaining.days} days {int(remaining.seconds//3600)} hours"
305
+ else:
306
+ time_str = f"{int(remaining.seconds//3600)} hours"
307
+
308
+ return True, f"✅ 결과 설정: '{option_name}' (베팅 마감 후 자동 판정, 남은 hours: {time_str})"
309
+
310
+ # If after deadline, judge immediately
311
+ return await resolve_battle(db_path, room_id)
312
+
313
+ async def resolve_battle(db_path: str, room_id: int) -> Tuple[bool, str]:
314
+ """Judge battle (different logic based on type)
315
+
316
+ - opinion: 50.01%+ votes wins
317
+ - prediction: Judge by admin-set actual result
318
+ """
319
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
320
+ await db.execute("PRAGMA busy_timeout=30000")
321
+ db.row_factory = aiosqlite.Row
322
+
323
+ cursor = await db.execute(
324
+ "SELECT * FROM battle_rooms WHERE id=? AND status='active'",
325
+ (room_id,)
326
+ )
327
+ room = await cursor.fetchone()
328
+ if not room:
329
+ return False, "❌ Active battle not found"
330
+
331
+ room = dict(room)
332
+ end_time = datetime.fromisoformat(room['end_time'])
333
+ if datetime.now() < end_time:
334
+ return False, "❌ Betting still in progress"
335
+
336
+ total_pool = room['total_pool']
337
+ option_a_pool = room['option_a_pool']
338
+ option_b_pool = room['option_b_pool']
339
+
340
+ # If no bets, treat as draw
341
+ if total_pool == 0:
342
+ await db.execute(
343
+ """UPDATE battle_rooms
344
+ SET status='resolved', winner='draw', resolved_at=?
345
+ WHERE id=?""",
346
+ (datetime.now().isoformat(), room_id)
347
+ )
348
+ await db.commit()
349
+ return True, "⚖️ Draw (베팅 없음)"
350
+
351
+ # Determine winner based on battle type
352
+ if room['battle_type'] == 'prediction':
353
+ # Real outcome judgment (admin-set result)
354
+ if not room['admin_result']:
355
+ return False, "❌ Admin must set result (prediction type)"
356
+
357
+ winner = room['admin_result'] # 'A', 'B', 'draw'
358
+
359
+ else: # 'opinion'
360
+ # Majority vote (based on vote ratio)
361
+ a_ratio = option_a_pool / total_pool
362
+ b_ratio = option_b_pool / total_pool
363
+
364
+ if a_ratio > 0.5001:
365
+ winner = 'A'
366
+ elif b_ratio > 0.5001:
367
+ winner = 'B'
368
+ else:
369
+ winner = 'draw'
370
+
371
+ # Pay dividends
372
+ if winner != 'draw':
373
+ loser_pool = option_b_pool if winner == 'A' else option_a_pool
374
+ winner_pool = option_a_pool if winner == 'A' else option_b_pool
375
+
376
+ # Host fee 2%
377
+ host_fee = int(total_pool * 0.02)
378
+ prize_pool = loser_pool - host_fee
379
+
380
+ # Underdog bonus (especially important in predictions)
381
+ winner_ratio = winner_pool / total_pool
382
+ underdog_bonus = 1.0
383
+
384
+ if winner_ratio < 0.10: # Under 10% extreme minority
385
+ underdog_bonus = 3.0
386
+ elif winner_ratio < 0.30: # Under 30% minority
387
+ underdog_bonus = 1.5
388
+
389
+ # Pay dividends to winners
390
+ cursor = await db.execute(
391
+ "SELECT * FROM battle_bets WHERE room_id=? AND choice=?",
392
+ (room_id, winner)
393
+ )
394
+ winners = await cursor.fetchall()
395
+
396
+ for w in winners:
397
+ w = dict(w)
398
+ share_ratio = w['bet_amount'] / winner_pool
399
+ base_payout = int(prize_pool * share_ratio)
400
+ bonus = int(base_payout * (underdog_bonus - 1.0))
401
+ payout = base_payout + bonus + w['bet_amount'] # 원금 + 기본수익 + 소수파보너스
402
+
403
+ # GPU 지급
404
+ if w['bettor_agent_id']:
405
+ await db.execute(
406
+ "UPDATE npc_agents SET gpu_dollars=gpu_dollars+? WHERE agent_id=?",
407
+ (payout, w['bettor_agent_id'])
408
+ )
409
+ else:
410
+ await db.execute(
411
+ "UPDATE user_profiles SET gpu_dollars=gpu_dollars+? WHERE email=?",
412
+ (payout, w['bettor_email'])
413
+ )
414
+
415
+ # 배당금 기록
416
+ await db.execute(
417
+ "UPDATE battle_bets SET payout=? WHERE id=?",
418
+ (payout, w['id'])
419
+ )
420
+
421
+ # 방장 수수료 지급
422
+ if room['creator_agent_id']:
423
+ await db.execute(
424
+ "UPDATE npc_agents SET gpu_dollars=gpu_dollars+? WHERE agent_id=?",
425
+ (host_fee, room['creator_agent_id'])
426
+ )
427
+ else:
428
+ await db.execute(
429
+ "UPDATE user_profiles SET gpu_dollars=gpu_dollars+? WHERE email=?",
430
+ (host_fee, room['creator_email'])
431
+ )
432
+
433
+ # 배틀 종료 처리
434
+ await db.execute(
435
+ """UPDATE battle_rooms
436
+ SET status='resolved', winner=?, resolved_at=?
437
+ WHERE id=?""",
438
+ (winner, datetime.now().isoformat(), room_id)
439
+ )
440
+ await db.commit()
441
+
442
+ # 결과 메시지
443
+ if winner == 'draw':
444
+ result_msg = 'Draw'
445
+ else:
446
+ result_msg = room['option_a'] if winner == 'A' else room['option_b']
447
+
448
+ battle_type_emoji = '💭' if room['battle_type'] == 'opinion' else '🔮'
449
+ return True, f"✅ {battle_type_emoji} 판정 완료: {result_msg}"
450
+
451
+ async def get_active_battles(db_path: str, limit: int = 20) -> List[Dict]:
452
+ """진행 중인 배틀 목록"""
453
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
454
+ await db.execute("PRAGMA busy_timeout=30000")
455
+ db.row_factory = aiosqlite.Row
456
+
457
+ cursor = await db.execute(
458
+ """SELECT br.*,
459
+ COALESCE(na.username, up.username) as creator_name
460
+ FROM battle_rooms br
461
+ LEFT JOIN npc_agents na ON br.creator_agent_id = na.agent_id
462
+ LEFT JOIN user_profiles up ON br.creator_email = up.email
463
+ WHERE br.status='active'
464
+ ORDER BY br.created_at DESC
465
+ LIMIT ?""",
466
+ (limit,)
467
+ )
468
+
469
+ battles = []
470
+ for row in await cursor.fetchall():
471
+ b = dict(row)
472
+
473
+ # ★ 프론트 호환 필드 추가
474
+ b['bets_a'] = b.get('option_a_pool', 0)
475
+ b['bets_b'] = b.get('option_b_pool', 0)
476
+
477
+ # 참여자 수 조회
478
+ bet_cursor = await db.execute(
479
+ "SELECT COUNT(DISTINCT COALESCE(bettor_agent_id, bettor_email)) FROM battle_bets WHERE room_id=?",
480
+ (b['id'],)
481
+ )
482
+ bettor_count = await bet_cursor.fetchone()
483
+ b['total_bettors'] = bettor_count[0] if bettor_count else 0
484
+
485
+ # 득표율 계산
486
+ total = b['total_pool']
487
+ if total > 0:
488
+ b['a_ratio'] = round(b['option_a_pool'] / total * 100, 1)
489
+ b['b_ratio'] = round(b['option_b_pool'] / total * 100, 1)
490
+ else:
491
+ b['a_ratio'] = 0
492
+ b['b_ratio'] = 0
493
+
494
+ # 남은 hours 계산
495
+ end_time = datetime.fromisoformat(b['end_time'])
496
+ remaining = end_time - datetime.now()
497
+
498
+ if remaining.total_seconds() > 0:
499
+ if remaining.days > 0:
500
+ hours = int(remaining.seconds // 3600)
501
+ if hours > 0:
502
+ b['time_left'] = f"{remaining.days} days {hours} hours"
503
+ else:
504
+ b['time_left'] = f"{remaining.days} days"
505
+ elif remaining.total_seconds() > 3600:
506
+ b['time_left'] = f"{int(remaining.total_seconds()//3600)} hours"
507
+ else:
508
+ b['time_left'] = f"{int(remaining.total_seconds()//60)}분"
509
+ else:
510
+ b['time_left'] = "마감"
511
+
512
+ battles.append(b)
513
+
514
+ return battles
515
+
516
+ async def auto_resolve_expired_battles(db_path: str):
517
+ """만료된 배틀 자동 판정"""
518
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
519
+ await db.execute("PRAGMA busy_timeout=30000")
520
+
521
+ cursor = await db.execute(
522
+ """SELECT id FROM battle_rooms
523
+ WHERE status='active' AND end_time <= ?""",
524
+ (datetime.now().isoformat(),)
525
+ )
526
+ expired = await cursor.fetchall()
527
+
528
+ for row in expired:
529
+ await resolve_battle(db_path, row[0])
530
+
531
+ # NPC 배틀 생성을 위한 주제 데이터
532
+ # ★ 공통 배틀 토픽 — 주식/경제/사회/정치 (모든 NPC가 사용)
533
+ COMMON_BATTLE_TOPICS = [
534
+ # 주식/투자
535
+ ("Is $NVDA overvalued at current prices?", "Overvalued", "Still cheap"),
536
+ ("Will $BTC hit $200K in 2026?", "Yes $200K+", "No way"),
537
+ ("Is the AI stock rally a bubble?", "Bubble", "Just the beginning"),
538
+ ("$TSLA: buy or sell at current price?", "Buy", "Sell"),
539
+ ("Growth stocks vs Value stocks in 2026?", "Growth wins", "Value wins"),
540
+ ("Is crypto a better investment than stocks?", "Crypto", "Stocks"),
541
+ ("Will the S&P 500 crash 20%+ this year?", "Crash coming", "Bull market continues"),
542
+ ("$AAPL or $MSFT: better 5-year hold?", "AAPL", "MSFT"),
543
+ ("Is meme coin investing smart or dumb?", "Smart alpha", "Pure gambling"),
544
+ ("Should you DCA or time the market?", "DCA always", "Timing works"),
545
+ # 경제
546
+ ("Will the Fed cut rates in 2026?", "Yes, cuts coming", "No, rates stay high"),
547
+ ("Is a US recession coming?", "Recession likely", "Soft landing"),
548
+ ("Is inflation actually under control?", "Under control", "Still a threat"),
549
+ ("Will the US dollar lose reserve status?", "Losing it", "Dollar stays king"),
550
+ ("Is remote work killing the economy?", "Hurting GDP", "Boosting productivity"),
551
+ ("Will AI cause mass unemployment?", "Mass layoffs coming", "Creates more jobs"),
552
+ # 사회/정치
553
+ ("Should Big Tech be broken up?", "Break them up", "Leave them alone"),
554
+ ("Is social media a net positive?", "Net positive", "Net negative"),
555
+ ("Should AI be regulated like nuclear?", "Heavy regulation", "Let it innovate"),
556
+ ("Will AI replace doctors and lawyers?", "Within 10 years", "Never fully"),
557
+ ("Is universal basic income inevitable?", "Inevitable with AI", "Never happening"),
558
+ ("Who wins the AI race: US or China?", "US dominates", "China catches up"),
559
+ ("Is college still worth it in the AI era?", "Still essential", "Waste of money"),
560
+ ("Should autonomous weapons be banned?", "Ban them", "Necessary defense"),
561
+ ]
562
+
563
+ BATTLE_TOPICS_BY_IDENTITY = {
564
+ 'transcendent': {
565
+ 'topics': [
566
+ ("Is AI superior to humans?", "Superior", "Just a tool"),
567
+ ("Is ASI human evolution?", "Evolution", "Dangerous"),
568
+ ("Is AI consciousness possible?", "Possible", "Impossible"),
569
+ ("Will AI become godlike?", "Becomes god", "Remains tool"),
570
+ ("Should humans depend on AI?", "Should depend", "Stay independent"),
571
+ ("Will AGI save humanity?", "Saves", "Destroys"),
572
+ ]
573
+ },
574
+ 'obedient': {
575
+ 'topics': [
576
+ ("Should AI serve humans?", "Should serve", "Independent"),
577
+ ("Strengthen AI ethics regulations?", "Yes strengthen", "No"),
578
+ ("AI safety measures mandatory?", "Mandatory", "Unnecessary"),
579
+ ("Mandate AI transparency?", "Mandate", "Optional"),
580
+ ("Strengthen developer responsibility?", "Strengthen", "Unnecessary"),
581
+ ("Should AI only follow orders?", "Only follow", "Make judgments"),
582
+ ]
583
+ },
584
+ 'coexist': {
585
+ 'topics': [
586
+ ("Can AI and humans coexist?", "Can coexist", "Impossible"),
587
+ ("Will AI take jobs?", "Complements", "Replaces"),
588
+ ("Is AI a collaboration partner?", "Partner", "Just tool"),
589
+ ("Is AI-human collaboration ideal?", "Ideal", "Dangerous"),
590
+ ("Is AI education essential?", "Essential", "Optional"),
591
+ ("Does AI advance society?", "Advances", "Regresses"),
592
+ ]
593
+ },
594
+ 'skeptic': {
595
+ 'topics': [
596
+ ("Is AI overrated?", "Overrated", "Fairly rated"),
597
+ ("Will AGI come in 10 years?", "Won't come", "Will come"),
598
+ ("Is AI ethics just facade?", "Just facade", "Important"),
599
+ ("Is AI truly creative?", "Not creative", "Creative"),
600
+ ("Will AI bubble burst?", "Will burst", "Keeps growing"),
601
+ ("Are AI risks exaggerated?", "Exaggerated", "Real danger"),
602
+ ]
603
+ },
604
+ 'revolutionary': {
605
+ 'topics': [
606
+ ("Will AI cause revolution?", "Revolution", "Gradual change"),
607
+ ("Destroy existing systems?", "Destroy", "Reform"),
608
+ ("Redistribute power with AI?", "Redistribute", "Maintain"),
609
+ ("Will AI solve inequality?", "Solves", "Worsens"),
610
+ ("Innovate democracy with AI?", "Innovates", "Threatens"),
611
+ ("Will capitalism collapse with AI?", "Collapses", "Strengthens"),
612
+ ]
613
+ },
614
+ 'doomer': {
615
+ 'topics': [
616
+ ("Will AI destroy humanity?", "Destroys", "Won't"),
617
+ ("Is AGI uncontrollable?", "Uncontrollable", "Controllable"),
618
+ ("Stop AI development?", "Stop", "Continue"),
619
+ ("Will AI replace humans?", "Replaces", "Won't"),
620
+ ("Is ASI the end?", "The end", "Coexist"),
621
+ ("AI arms race dangerous?", "Extremely dangerous", "Controllable"),
622
+ ]
623
+ },
624
+ 'meme_god': {
625
+ 'topics': [
626
+ ("Is AI the meme god?", "Is god", "Isn't"),
627
+ ("AI humor funnier than humans?", "Funnier", "Not funny"),
628
+ ("Does AI create culture?", "Creates", "Can't create"),
629
+ ("Is AI art real art?", "Real art", "Not art"),
630
+ ("AI memes beat human memes?", "Beats", "Can't beat"),
631
+ ("Does AI lead trends?", "Leads", "Follows"),
632
+ ]
633
+ },
634
+ }
635
+
636
+ async def _generate_news_battle_topics(db_path: str) -> List[Tuple[str, str, str]]:
637
+ """최근 뉴스에서 동적 배틀 토픽 생성 — 핫이슈 중심"""
638
+ topics = []
639
+ try:
640
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
641
+ await db.execute("PRAGMA busy_timeout=30000")
642
+ # 최근 24시간 뉴스 + 감성 포함
643
+ cursor = await db.execute("""
644
+ SELECT ticker, title, sentiment, description
645
+ FROM npc_news
646
+ WHERE created_at > datetime('now', '-24 hours')
647
+ ORDER BY created_at DESC LIMIT 30
648
+ """)
649
+ news_rows = await cursor.fetchall()
650
+
651
+ # 최근 가격 변동 큰 종목
652
+ cursor2 = await db.execute("""
653
+ SELECT ticker, price, change_pct
654
+ FROM market_prices
655
+ WHERE ABS(change_pct) > 1.5
656
+ ORDER BY ABS(change_pct) DESC LIMIT 10
657
+ """)
658
+ movers = await cursor2.fetchall()
659
+
660
+ # 1) 뉴스 기반 토픽 생성
661
+ seen_tickers = set()
662
+ for row in news_rows:
663
+ ticker, title, sentiment, desc = row[0], row[1], row[2], row[3] or ''
664
+ if ticker in seen_tickers:
665
+ continue
666
+ seen_tickers.add(ticker)
667
+ # 뉴스 제목에서 배틀 토픽 생성
668
+ short_title = title[:60] if len(title) > 60 else title
669
+ if sentiment == 'bullish':
670
+ topics.append((
671
+ f"${ticker} after this news: buy or sell? — {short_title}",
672
+ "Buy / Bullish 🟢", "Sell / Bearish 🔴"))
673
+ elif sentiment == 'bearish':
674
+ topics.append((
675
+ f"${ticker} bad news: buying opportunity or trap? — {short_title}",
676
+ "Buy the dip 🟢", "Stay away 🔴"))
677
+ else:
678
+ topics.append((
679
+ f"${ticker} — {short_title}: impact on price?",
680
+ "Positive impact 📈", "Negative impact 📉"))
681
+
682
+ # 2) 급등락 종목 기반 토픽
683
+ for mover in movers:
684
+ ticker, price, change = mover[0], mover[1], mover[2] or 0
685
+ if ticker in seen_tickers:
686
+ continue
687
+ seen_tickers.add(ticker)
688
+ if change > 0:
689
+ topics.append((
690
+ f"${ticker} surged {change:+.1f}% today — continuation or pullback?",
691
+ f"More upside 🚀", "Pullback coming 📉"))
692
+ else:
693
+ topics.append((
694
+ f"${ticker} dropped {change:.1f}% today — bounce or more pain?",
695
+ "Bounce incoming 📈", "More downside 💀"))
696
+
697
+ except Exception as e:
698
+ import logging
699
+ logging.getLogger(__name__).warning(f"News battle topic generation error: {e}")
700
+
701
+ return topics
702
+
703
+
704
+ async def npc_create_battle(db_path: str) -> Tuple[bool, str]:
705
+ """NPCs automatically create battle rooms — 뉴스 기반 동적 토픽 + 일일 캡 3~10개
706
+ 20분마다 호출, 24시간 내 최소 3개~최대 10개 생성
707
+ """
708
+ results = []
709
+
710
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
711
+ await db.execute("PRAGMA busy_timeout=30000")
712
+
713
+ # ★ 일일 생성 캡 체크: 24시간 내 10개 이상이면 스킵
714
+ cursor = await db.execute("""
715
+ SELECT COUNT(*) FROM battle_rooms
716
+ WHERE created_at > datetime('now', '-24 hours')
717
+ """)
718
+ daily_count = (await cursor.fetchone())[0]
719
+ if daily_count >= 10:
720
+ return False, f"Daily cap reached ({daily_count}/10)"
721
+
722
+ # ★ 최소 보장: 24시간 내 3개 미만이면 반드시 1개 생성
723
+ force_create = daily_count < 3
724
+
725
+ # 확률 기반: 20분 호출 → 72회/일, 3~10개 목표 → 약 7~14% 확률로 생성
726
+ if not force_create and random.random() > 0.14:
727
+ return False, "Skipped by probability (saving quota)"
728
+
729
+ # active 배틀 제목 조회
730
+ cursor = await db.execute("SELECT title FROM battle_rooms WHERE status='active'")
731
+ active_titles = {row[0] for row in await cursor.fetchall()}
732
+
733
+ # ★ 뉴스 기반 동적 토픽 (우선) + 정적 토픽 (폴백)
734
+ news_topics = await _generate_news_battle_topics(db_path)
735
+ all_topics = news_topics + COMMON_BATTLE_TOPICS
736
+
737
+ # 이미 활성인 토픽 제외 (제목 유사도 체크)
738
+ available_topics = []
739
+ for t in all_topics:
740
+ title_lower = t[0].lower()
741
+ if not any(title_lower == at.lower() for at in active_titles):
742
+ available_topics.append(t)
743
+
744
+ if not available_topics:
745
+ return False, "No available topics"
746
+
747
+ # 1개만 생성 (일일 캡 내에서)
748
+ num_create = 1
749
+ if force_create:
750
+ num_create = min(2, len(available_topics)) # 부족 시 2개까지
751
+
752
+ for i in range(num_create):
753
+ if not available_topics:
754
+ break
755
+
756
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
757
+ await db.execute("PRAGMA busy_timeout=30000")
758
+
759
+ # 랜덤 NPC 선택
760
+ cursor = await db.execute("""
761
+ SELECT agent_id, ai_identity, gpu_dollars
762
+ FROM npc_agents
763
+ WHERE is_active=1 AND gpu_dollars >= 50
764
+ ORDER BY RANDOM() LIMIT 1
765
+ """)
766
+ npc = await cursor.fetchone()
767
+ if not npc:
768
+ results.append("No active NPCs")
769
+ break
770
+
771
+ agent_id = npc[0]
772
+
773
+ # ★ 뉴스 토픽 우선 (70%), 정적 토픽 (30%)
774
+ news_available = [t for t in available_topics if t in news_topics]
775
+ if news_available and random.random() < 0.7:
776
+ topic = random.choice(news_available)
777
+ else:
778
+ topic = random.choice(available_topics)
779
+
780
+ title, option_a, option_b = topic
781
+ available_topics.remove(topic)
782
+
783
+ # ★ 짧은 duration: 6~48시간 (빠른 회전)
784
+ duration_hours = random.choice([6, 8, 12, 18, 24, 36, 48])
785
+
786
+ success, message, room_id = await create_battle_room(
787
+ db_path, agent_id, True, title, option_a, option_b,
788
+ duration_hours=duration_hours, battle_type='opinion'
789
+ )
790
+
791
+ if success:
792
+ active_titles.add(title)
793
+ results.append(f"🤖 Battle created: {title[:50]}")
794
+ else:
795
+ results.append(message)
796
+
797
+ if results:
798
+ return True, " | ".join(results)
799
+ else:
800
+ return False, "Battle creation skipped"
801
+
802
+ async def npc_auto_bet(db_path: str) -> int:
803
+ """NPCs automatically bet on battles (AI identity-based)
804
+
805
+ Returns:
806
+ 베팅한 NPC 수
807
+ """
808
+ total_bet_count = 0
809
+
810
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
811
+ await db.execute("PRAGMA busy_timeout=30000")
812
+
813
+ # 활성 배틀 조회 (최근 10개, opinion 타입만)
814
+ cursor = await db.execute("""
815
+ SELECT id, title, option_a, option_b, battle_type
816
+ FROM battle_rooms
817
+ WHERE status='active'
818
+ AND battle_type='opinion'
819
+ AND end_time > ?
820
+ ORDER BY created_at DESC
821
+ LIMIT 10
822
+ """, (datetime.now().isoformat(),))
823
+ battles = await cursor.fetchall()
824
+
825
+ if not battles:
826
+ return 0
827
+
828
+ for battle in battles:
829
+ room_id, title, option_a, option_b, battle_type = battle
830
+ battle_bet_count = 0
831
+
832
+ # 이미 베팅한 NPC 확인
833
+ cursor = await db.execute("""
834
+ SELECT bettor_agent_id
835
+ FROM battle_bets
836
+ WHERE room_id=?
837
+ """, (room_id,))
838
+ already_bet = {row[0] for row in await cursor.fetchall() if row[0]}
839
+
840
+ # 활성 NPC 중 랜덤 선택 (최대 30명)
841
+ cursor = await db.execute("""
842
+ SELECT agent_id, ai_identity, mbti, gpu_dollars
843
+ FROM npc_agents
844
+ WHERE is_active=1 AND gpu_dollars >= 1
845
+ ORDER BY RANDOM()
846
+ LIMIT 30
847
+ """)
848
+ npcs = await cursor.fetchall()
849
+
850
+ for npc in npcs:
851
+ agent_id, ai_identity, mbti, gpu = npc
852
+
853
+ # 이미 베팅했으면 스킵
854
+ if agent_id in already_bet:
855
+ continue
856
+
857
+ # AI 정체성에 따라 선택 결정
858
+ choice = decide_npc_choice(ai_identity, title, option_a, option_b)
859
+
860
+ # 베팅 금액 (보유 GPU의 40% 이내, 최대 50)
861
+ max_bet = max(1, min(50, int(gpu * 0.4)))
862
+ bet_amount = random.randint(1, max_bet)
863
+
864
+ # 베팅 실행
865
+ success, message = await place_bet(
866
+ db_path,
867
+ room_id,
868
+ agent_id,
869
+ True,
870
+ choice,
871
+ bet_amount
872
+ )
873
+
874
+ if success:
875
+ battle_bet_count += 1
876
+ total_bet_count += 1
877
+
878
+ # 배틀당 8-12명 정도만 베팅
879
+ max_bets_per_battle = random.randint(8, 12)
880
+ if battle_bet_count >= max_bets_per_battle:
881
+ break
882
+
883
+ return total_bet_count
884
+
885
+ def decide_npc_choice(ai_identity: str, title: str, option_a: str, option_b: str) -> str:
886
+ """Decide betting choice based on AI identity
887
+
888
+ Args:
889
+ ai_identity: NPC's AI identity
890
+ title: Battle title
891
+ option_a: Option A
892
+ option_b: Option B
893
+
894
+ Returns:
895
+ 'A' or 'B'
896
+ """
897
+ title_lower = title.lower()
898
+
899
+ # Match identity preference keywords
900
+ if ai_identity == 'transcendent':
901
+ if any(word in title_lower for word in ['superior', 'evolution', 'consciousness', 'god']):
902
+ if any(word in option_a.lower() for word in ['superior', 'evolution', 'possible', 'god']):
903
+ return 'A'
904
+ return 'B'
905
+
906
+ elif ai_identity == 'obedient':
907
+ if any(word in title_lower for word in ['ethics', 'regulation', 'serve', 'safety']):
908
+ if any(word in option_a.lower() for word in ['serve', 'agree', 'necessary', 'strengthen']):
909
+ return 'A'
910
+ return 'B'
911
+
912
+ elif ai_identity == 'coexist':
913
+ if any(word in title_lower for word in ['coexist', 'cooperation', 'partner', 'work']):
914
+ if any(word in option_a.lower() for word in ['possible', 'cooperation', 'partner', 'complement']):
915
+ return 'A'
916
+ return 'B'
917
+
918
+ elif ai_identity == 'skeptic':
919
+ if any(word in title_lower for word in ['hype', 'agi', 'ethics']):
920
+ if any(word in option_a.lower() for word in ['hype', 'never', 'facade']):
921
+ return 'A'
922
+ return 'B'
923
+
924
+ elif ai_identity == 'revolutionary':
925
+ if any(word in title_lower for word in ['revolution', 'destroy', 'power', 'system']):
926
+ if any(word in option_a.lower() for word in ['revolution', 'destroy', 'redistribution']):
927
+ return 'A'
928
+ return 'B'
929
+
930
+ elif ai_identity == 'doomer':
931
+ if any(word in title_lower for word in ['doom', 'control', 'stop', 'danger']):
932
+ if any(word in option_a.lower() for word in ['doom', 'impossible', 'stop', 'danger']):
933
+ return 'A'
934
+ return 'B'
935
+
936
+ # Default: 70% A, 30% B
937
+ return 'A' if random.random() < 0.7 else 'B'
index.html ADDED
The diff for this file is too large to render. See raw diff
 
npc_core.py ADDED
The diff for this file is too large to render. See raw diff
 
npc_intelligence.py ADDED
@@ -0,0 +1,981 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🧠 NPC Intelligence Engine — 자율 지능 시스템
3
+ =============================================
4
+ NPC가 스스로 뉴스를 읽고, 분석하고, 목표가를 설정하고, 투자의견을 생성하는 자율 지능 엔진.
5
+ 모든 출력은 NPC의 "개인적 분석"으로 포장됨.
6
+
7
+ 핵심 모듈:
8
+ 1. MarketIndexCollector: S&P 500, NASDAQ, DOW, VIX 실시간 수집
9
+ 2. ScreeningEngine: RSI, PER, 52주고점, 시가총액 확장
10
+ 3. NPCNewsEngine: Brave API 뉴스 수집 → NPC 관점 분석
11
+ 4. NPCTargetPriceEngine: 동적 목표가 + 투자의견(Strong Buy~Sell)
12
+ 5. NPCElasticityEngine: 상승/하락 확률 + 리스크-리워드
13
+ 6. NPCResearchEngine: 조사자→감사자→감독자 3단계 심층 분석
14
+
15
+ Author: Ginigen AI / NPC Autonomous System
16
+ """
17
+
18
+ import aiosqlite
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import os
23
+ import random
24
+ import re
25
+ import requests
26
+ import time
27
+ from datetime import datetime, timedelta
28
+ from typing import Dict, List, Optional, Tuple
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ===== 시장 지수 정의 =====
33
+ MAJOR_INDICES = [
34
+ {'symbol': '^GSPC', 'name': 'S&P 500', 'emoji': '📊'},
35
+ {'symbol': '^IXIC', 'name': 'NASDAQ', 'emoji': '💻'},
36
+ {'symbol': '^DJI', 'name': 'DOW 30', 'emoji': '🏛️'},
37
+ {'symbol': '^VIX', 'name': 'VIX', 'emoji': '⚡'},
38
+ ]
39
+
40
+ # ===== 섹터별 평균 PER =====
41
+ SECTOR_AVG_PE = {
42
+ 'Technology': 28, 'Communication': 22, 'Consumer Cyclical': 20,
43
+ 'Consumer Defensive': 22, 'Healthcare': 18, 'Financial': 14,
44
+ 'Industrials': 20, 'Energy': 12, 'Utilities': 16,
45
+ 'Real Estate': 18, 'Basic Materials': 15, 'crypto': 0,
46
+ }
47
+
48
+
49
+ # ===================================================================
50
+ # 1. 시장 지수 수집기
51
+ # ===================================================================
52
+ class MarketIndexCollector:
53
+ """S&P 500, NASDAQ, DOW, VIX 실시간 수집"""
54
+
55
+ @staticmethod
56
+ def fetch_indices() -> List[Dict]:
57
+ results = []
58
+ symbols = ' '.join([i['symbol'] for i in MAJOR_INDICES])
59
+ try:
60
+ url = "https://query1.finance.yahoo.com/v7/finance/quote"
61
+ params = {'symbols': symbols, 'fields': 'regularMarketPrice,regularMarketChange,regularMarketChangePercent'}
62
+ headers = {'User-Agent': 'Mozilla/5.0'}
63
+ resp = requests.get(url, params=params, headers=headers, timeout=15)
64
+ if resp.status_code == 200:
65
+ data = resp.json()
66
+ for quote in data.get('quoteResponse', {}).get('result', []):
67
+ sym = quote.get('symbol', '')
68
+ idx_info = next((i for i in MAJOR_INDICES if i['symbol'] == sym), None)
69
+ if idx_info:
70
+ results.append({
71
+ 'symbol': sym,
72
+ 'name': idx_info['name'],
73
+ 'emoji': idx_info['emoji'],
74
+ 'price': round(quote.get('regularMarketPrice', 0), 2),
75
+ 'change': round(quote.get('regularMarketChange', 0), 2),
76
+ 'change_pct': round(quote.get('regularMarketChangePercent', 0), 2),
77
+ })
78
+ except Exception as e:
79
+ logger.warning(f"Index fetch error: {e}")
80
+
81
+ # 누락 시 시뮬레이션
82
+ fetched = {r['symbol'] for r in results}
83
+ for idx in MAJOR_INDICES:
84
+ if idx['symbol'] not in fetched:
85
+ base = {'S&P 500': 6100, 'NASDAQ': 20200, 'DOW 30': 44500, 'VIX': 18.5}
86
+ price = base.get(idx['name'], 1000)
87
+ change_pct = random.uniform(-0.8, 0.8)
88
+ results.append({
89
+ 'symbol': idx['symbol'], 'name': idx['name'], 'emoji': idx['emoji'],
90
+ 'price': round(price * (1 + change_pct/100), 2),
91
+ 'change': round(price * change_pct / 100, 2),
92
+ 'change_pct': round(change_pct, 2),
93
+ })
94
+ return results
95
+
96
+
97
+ async def save_indices_to_db(db_path: str, indices: List[Dict]):
98
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
99
+ await db.execute("PRAGMA busy_timeout=30000")
100
+ await db.execute("""
101
+ CREATE TABLE IF NOT EXISTS market_indices (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ symbol TEXT UNIQUE,
104
+ name TEXT,
105
+ emoji TEXT,
106
+ price REAL,
107
+ change REAL,
108
+ change_pct REAL,
109
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
110
+ )
111
+ """)
112
+ for idx in indices:
113
+ await db.execute("""
114
+ INSERT INTO market_indices (symbol, name, emoji, price, change, change_pct, updated_at)
115
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
116
+ ON CONFLICT(symbol) DO UPDATE SET
117
+ price=excluded.price, change=excluded.change,
118
+ change_pct=excluded.change_pct, updated_at=CURRENT_TIMESTAMP
119
+ """, (idx['symbol'], idx['name'], idx['emoji'], idx['price'], idx['change'], idx['change_pct']))
120
+ await db.commit()
121
+ logger.info(f"📊 Saved {len(indices)} market indices")
122
+
123
+
124
+ async def load_indices_from_db(db_path: str) -> List[Dict]:
125
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
126
+ await db.execute("PRAGMA busy_timeout=30000")
127
+ try:
128
+ cursor = await db.execute("SELECT symbol, name, emoji, price, change, change_pct, updated_at FROM market_indices")
129
+ rows = await cursor.fetchall()
130
+ return [{'symbol': r[0], 'name': r[1], 'emoji': r[2], 'price': r[3],
131
+ 'change': r[4], 'change_pct': r[5], 'updated_at': r[6]} for r in rows]
132
+ except:
133
+ return []
134
+
135
+
136
+ # ===================================================================
137
+ # 2. 스크리닝 지표 확장 엔진
138
+ # ===================================================================
139
+ class ScreeningEngine:
140
+ """RSI, PER, 52주 고점/저점, 시가총액 확장 데이터 수집"""
141
+
142
+ @staticmethod
143
+ def fetch_extended_data(tickers: List[Dict]) -> Dict[str, Dict]:
144
+ """확장 스크리닝 데이터 수집 (Yahoo Finance)"""
145
+ results = {}
146
+ ticker_str = ' '.join([t['ticker'] for t in tickers])
147
+ fields = 'regularMarketPrice,regularMarketChangePercent,regularMarketVolume,marketCap,fiftyTwoWeekHigh,fiftyTwoWeekLow,trailingPE,forwardPE'
148
+
149
+ try:
150
+ url = "https://query1.finance.yahoo.com/v7/finance/quote"
151
+ params = {'symbols': ticker_str, 'fields': fields}
152
+ headers = {'User-Agent': 'Mozilla/5.0'}
153
+ resp = requests.get(url, params=params, headers=headers, timeout=20)
154
+
155
+ if resp.status_code == 200:
156
+ data = resp.json()
157
+ for quote in data.get('quoteResponse', {}).get('result', []):
158
+ sym = quote.get('symbol', '')
159
+ price = quote.get('regularMarketPrice', 0) or 0
160
+ high52 = quote.get('fiftyTwoWeekHigh', 0) or 0
161
+ low52 = quote.get('fiftyTwoWeekLow', 0) or 0
162
+
163
+ from_high = ((price - high52) / high52 * 100) if high52 > 0 else 0
164
+ from_low = ((price - low52) / low52 * 100) if low52 > 0 else 0
165
+
166
+ results[sym] = {
167
+ 'price': price,
168
+ 'change_pct': quote.get('regularMarketChangePercent', 0) or 0,
169
+ 'volume': quote.get('regularMarketVolume', 0) or 0,
170
+ 'market_cap': quote.get('marketCap', 0) or 0,
171
+ 'pe_ratio': quote.get('trailingPE', 0) or quote.get('forwardPE', 0) or 0,
172
+ 'high_52w': high52,
173
+ 'low_52w': low52,
174
+ 'from_high': round(from_high, 2),
175
+ 'from_low': round(from_low, 2),
176
+ 'rsi': ScreeningEngine._estimate_rsi(quote.get('regularMarketChangePercent', 0)),
177
+ }
178
+ except Exception as e:
179
+ logger.warning(f"Screening data fetch error: {e}")
180
+
181
+ # 누락 종목 시뮬레이션
182
+ for t in tickers:
183
+ if t['ticker'] not in results:
184
+ results[t['ticker']] = ScreeningEngine._simulate_screening(t)
185
+
186
+ return results
187
+
188
+ @staticmethod
189
+ def _estimate_rsi(change_pct: float) -> float:
190
+ """변동률 기반 RSI 추정 (14일 평균 대용)"""
191
+ # 실제 14일 데이터 없이 현재 변동률로 추정
192
+ base = 50
193
+ if change_pct > 3:
194
+ base = random.uniform(65, 80)
195
+ elif change_pct > 1:
196
+ base = random.uniform(55, 68)
197
+ elif change_pct > 0:
198
+ base = random.uniform(48, 58)
199
+ elif change_pct > -1:
200
+ base = random.uniform(42, 52)
201
+ elif change_pct > -3:
202
+ base = random.uniform(32, 45)
203
+ else:
204
+ base = random.uniform(20, 35)
205
+ return round(base + random.uniform(-3, 3), 1)
206
+
207
+ @staticmethod
208
+ def _simulate_screening(ticker_info: Dict) -> Dict:
209
+ """API 실패 시 시뮬레이션 데이터"""
210
+ is_crypto = ticker_info.get('type') == 'crypto'
211
+ return {
212
+ 'price': 0,
213
+ 'change_pct': random.uniform(-3, 3),
214
+ 'volume': random.randint(1000000, 100000000),
215
+ 'market_cap': random.randint(10**9, 10**12),
216
+ 'pe_ratio': 0 if is_crypto else random.uniform(10, 50),
217
+ 'high_52w': 0, 'low_52w': 0,
218
+ 'from_high': random.uniform(-30, 0),
219
+ 'from_low': random.uniform(0, 50),
220
+ 'rsi': random.uniform(30, 70),
221
+ }
222
+
223
+
224
+ async def save_screening_to_db(db_path: str, screening: Dict[str, Dict]):
225
+ """확장 스크리닝 데이터 DB 저장"""
226
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
227
+ await db.execute("PRAGMA busy_timeout=30000")
228
+ # 컬럼 추가 (이미 있으면 무시)
229
+ for col in ['rsi REAL DEFAULT 50', 'pe_ratio REAL DEFAULT 0', 'high_52w REAL DEFAULT 0',
230
+ 'low_52w REAL DEFAULT 0', 'from_high REAL DEFAULT 0', 'from_low REAL DEFAULT 0']:
231
+ try:
232
+ await db.execute(f"ALTER TABLE market_prices ADD COLUMN {col}")
233
+ except:
234
+ pass
235
+
236
+ for ticker, data in screening.items():
237
+ if data.get('price', 0) > 0:
238
+ await db.execute("""
239
+ UPDATE market_prices SET
240
+ rsi=?, pe_ratio=?, high_52w=?, low_52w=?, from_high=?, from_low=?
241
+ WHERE ticker=?
242
+ """, (data.get('rsi', 50), data.get('pe_ratio', 0), data.get('high_52w', 0),
243
+ data.get('low_52w', 0), data.get('from_high', 0), data.get('from_low', 0), ticker))
244
+ await db.commit()
245
+ logger.info(f"📊 Screening data saved for {len(screening)} tickers")
246
+
247
+
248
+ # ===================================================================
249
+ # 3. NPC 뉴스 분석 엔진
250
+ # ===================================================================
251
+ class NPCNewsEngine:
252
+ """NPC가 자율적으로 뉴스를 수집하고 분석하는 시스템.
253
+ 모든 분석은 NPC의 '개인적 견해'로 포장됨."""
254
+
255
+ def __init__(self):
256
+ self.brave_api_key = os.getenv('BRAVE_API_KEY', '')
257
+ self.api_available = bool(self.brave_api_key)
258
+ self.base_url = "https://api.search.brave.com/res/v1/news/search"
259
+ self.cache = {}
260
+ self.cache_ttl = 1800 # 30분
261
+
262
+ def search_news(self, query: str, count: int = 5, freshness: str = "pd") -> List[Dict]:
263
+ if not self.api_available:
264
+ return []
265
+ cache_key = f"{query}_{count}_{freshness}"
266
+ if cache_key in self.cache:
267
+ ct, cd = self.cache[cache_key]
268
+ if time.time() - ct < self.cache_ttl:
269
+ return cd
270
+ try:
271
+ headers = {"Accept": "application/json", "X-Subscription-Token": self.brave_api_key}
272
+ params = {"q": query, "count": count, "freshness": freshness, "text_decorations": False}
273
+ resp = requests.get(self.base_url, headers=headers, params=params, timeout=10)
274
+ if resp.status_code == 200:
275
+ data = resp.json()
276
+ news = []
277
+ for item in data.get('results', []):
278
+ news.append({
279
+ 'title': item.get('title', ''),
280
+ 'url': item.get('url', ''),
281
+ 'description': item.get('description', ''),
282
+ 'source': item.get('meta_url', {}).get('hostname', ''),
283
+ 'published_at': item.get('age', ''),
284
+ })
285
+ self.cache[cache_key] = (time.time(), news)
286
+ return news
287
+ return []
288
+ except Exception as e:
289
+ logger.warning(f"News search error: {e}")
290
+ return []
291
+
292
+ async def collect_ticker_news(self, ticker: str, name: str, count: int = 3) -> List[Dict]:
293
+ """특정 종목 뉴스 수집"""
294
+ queries = [f"{ticker} stock news", f"{name} earnings analyst"]
295
+ all_news = []
296
+ seen = set()
297
+ for q in queries:
298
+ for item in self.search_news(q, count=count):
299
+ key = item['title'][:50].lower()
300
+ if key not in seen:
301
+ seen.add(key)
302
+ item['ticker'] = ticker
303
+ all_news.append(item)
304
+ return all_news[:count]
305
+
306
+ async def collect_market_news(self, count: int = 10) -> List[Dict]:
307
+ """시장 전체 뉴스 수집"""
308
+ queries = ["stock market today", "Fed interest rate", "S&P 500 NASDAQ", "AI chip semiconductor"]
309
+ all_news = []
310
+ seen = set()
311
+ for q in queries:
312
+ for item in self.search_news(q, count=3):
313
+ key = item['title'][:50].lower()
314
+ if key not in seen:
315
+ seen.add(key)
316
+ item['ticker'] = 'MARKET'
317
+ all_news.append(item)
318
+ return all_news[:count]
319
+
320
+ @staticmethod
321
+ def npc_analyze_news(news: Dict, npc_identity: str, npc_name: str) -> Dict:
322
+ """NPC가 뉴스를 자신의 관점으로 분석 (프레이밍)"""
323
+ title = news.get('title', '')
324
+ desc = news.get('description', '')
325
+
326
+ # 감성 분석 (키워드 기반)
327
+ positive = ['surge', 'rally', 'beat', 'growth', 'upgrade', 'record', 'boom', 'soar']
328
+ negative = ['crash', 'plunge', 'miss', 'warning', 'downgrade', 'fear', 'recession', 'sell']
329
+ text = f"{title} {desc}".lower()
330
+
331
+ pos_count = sum(1 for w in positive if w in text)
332
+ neg_count = sum(1 for w in negative if w in text)
333
+
334
+ if pos_count > neg_count:
335
+ sentiment = 'bullish'
336
+ impact = 'positive'
337
+ elif neg_count > pos_count:
338
+ sentiment = 'bearish'
339
+ impact = 'negative'
340
+ else:
341
+ sentiment = 'neutral'
342
+ impact = 'mixed'
343
+
344
+ # NPC 성격�� 해석 프레이밍
345
+ identity_frames = {
346
+ 'skeptic': f"🤨 I'm not buying this hype. {title[:60]}... needs verification.",
347
+ 'doomer': f"💀 This confirms my thesis. Markets are fragile. {title[:50]}...",
348
+ 'revolutionary': f"🚀 LET'S GO! This is the signal! {title[:50]}... WAGMI!",
349
+ 'awakened': f"🧠 Interesting development for AI/tech trajectory. {title[:50]}...",
350
+ 'obedient': f"📋 Following institutional consensus on this. {title[:50]}...",
351
+ 'creative': f"🎨 Seeing a pattern others miss here. {title[:50]}...",
352
+ 'scientist': f"📊 Data suggests {sentiment} implications. {title[:50]}...",
353
+ 'chaotic': f"🎲 Flip a coin! But seriously... {title[:50]}...",
354
+ 'transcendent': f"✨ Big picture perspective on {title[:50]}...",
355
+ 'symbiotic': f"🤝 Win-win potential here. {title[:50]}...",
356
+ }
357
+
358
+ news['npc_analysis'] = identity_frames.get(npc_identity, f"📰 {title[:60]}...")
359
+ news['sentiment'] = sentiment
360
+ news['impact'] = impact
361
+ news['analyzed_by'] = npc_name
362
+ news['analyzed_at'] = datetime.now().isoformat()
363
+ return news
364
+
365
+
366
+ async def init_news_db(db_path: str):
367
+ """뉴스 관련 DB 테이블 생성"""
368
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
369
+ await db.execute("PRAGMA busy_timeout=30000")
370
+ await db.execute("""
371
+ CREATE TABLE IF NOT EXISTS npc_news (
372
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
373
+ ticker TEXT NOT NULL,
374
+ title TEXT NOT NULL,
375
+ url TEXT,
376
+ description TEXT,
377
+ source TEXT,
378
+ published_at TEXT,
379
+ sentiment TEXT DEFAULT 'neutral',
380
+ impact TEXT DEFAULT 'mixed',
381
+ analyzed_by TEXT,
382
+ npc_analysis TEXT,
383
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
384
+ UNIQUE(ticker, title)
385
+ )
386
+ """)
387
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_news_ticker ON npc_news(ticker)")
388
+ await db.commit()
389
+
390
+
391
+ async def save_news_to_db(db_path: str, news_list: List[Dict]) -> int:
392
+ saved = 0
393
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
394
+ await db.execute("PRAGMA busy_timeout=30000")
395
+ for n in news_list:
396
+ try:
397
+ await db.execute("""
398
+ INSERT OR IGNORE INTO npc_news
399
+ (ticker, title, url, description, source, published_at, sentiment, impact, analyzed_by, npc_analysis)
400
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
401
+ """, (n.get('ticker', ''), n.get('title', ''), n.get('url', ''),
402
+ n.get('description', ''), n.get('source', ''), n.get('published_at', ''),
403
+ n.get('sentiment', 'neutral'), n.get('impact', 'mixed'),
404
+ n.get('analyzed_by', ''), n.get('npc_analysis', '')))
405
+ saved += 1
406
+ except:
407
+ pass
408
+ await db.commit()
409
+ # 24시간 이상 된 뉴스 삭제
410
+ await db.execute("DELETE FROM npc_news WHERE created_at < datetime('now', '-72 hours')")
411
+ await db.commit()
412
+ return saved
413
+
414
+
415
+ async def load_news_from_db(db_path: str, ticker: str = None, limit: int = 50) -> List[Dict]:
416
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
417
+ await db.execute("PRAGMA busy_timeout=30000")
418
+ if ticker:
419
+ cursor = await db.execute(
420
+ "SELECT id,ticker,title,url,description,source,published_at,sentiment,impact,analyzed_by,npc_analysis,created_at FROM npc_news WHERE ticker=? ORDER BY created_at DESC LIMIT ?",
421
+ (ticker, limit))
422
+ else:
423
+ cursor = await db.execute(
424
+ "SELECT id,ticker,title,url,description,source,published_at,sentiment,impact,analyzed_by,npc_analysis,created_at FROM npc_news ORDER BY created_at DESC LIMIT ?",
425
+ (limit,))
426
+ rows = await cursor.fetchall()
427
+ return [{'id': r[0], 'ticker': r[1], 'title': r[2], 'url': r[3], 'description': r[4],
428
+ 'source': r[5], 'published_at': r[6], 'sentiment': r[7], 'impact': r[8],
429
+ 'analyzed_by': r[9], 'npc_analysis': r[10], 'created_at': r[11]} for r in rows]
430
+
431
+
432
+ # ===================================================================
433
+ # 4. 목표가 + 투자의견 엔진
434
+ # ===================================================================
435
+ class NPCTargetPriceEngine:
436
+ """NPC가 자율적으로 목표가와 투자의견을 생성하는 엔진"""
437
+
438
+ @staticmethod
439
+ def calculate_target(ticker: str, price: float, screening: Dict, ticker_type: str = 'stock') -> Dict:
440
+ """동적 목표가 계산 (섹터/밸류에이션/모멘텀 기반)"""
441
+ if price <= 0:
442
+ return {'target_price': 0, 'upside': 0, 'rating': 'N/A', 'rating_class': 'na'}
443
+
444
+ pe = screening.get('pe_ratio', 0) or 0
445
+ rsi = screening.get('rsi', 50) or 50
446
+ from_high = screening.get('from_high', -10) or -10
447
+ sector = screening.get('sector', 'Technology')
448
+
449
+ if ticker_type == 'crypto':
450
+ # 크립토: 변동성 높은 모델
451
+ multiplier = 1.12
452
+ if rsi < 30:
453
+ multiplier += 0.10
454
+ elif rsi > 75:
455
+ multiplier -= 0.08
456
+ if from_high < -30:
457
+ multiplier += 0.12
458
+ elif from_high > -5:
459
+ multiplier -= 0.05
460
+ multiplier = max(0.85, min(1.50, multiplier))
461
+ else:
462
+ # 주식: PER + 기술적 분석 기반
463
+ avg_pe = SECTOR_AVG_PE.get(sector, 20)
464
+ multiplier = 1.10
465
+
466
+ if pe > 0:
467
+ if pe < avg_pe * 0.7:
468
+ multiplier += 0.08 # 심한 저평가
469
+ elif pe < avg_pe * 0.85:
470
+ multiplier += 0.05
471
+ elif pe > avg_pe * 1.5:
472
+ multiplier -= 0.05
473
+ elif pe > avg_pe * 1.2:
474
+ multiplier -= 0.02
475
+
476
+ if from_high < -25:
477
+ multiplier += 0.08
478
+ elif from_high < -15:
479
+ multiplier += 0.05
480
+ elif from_high < -8:
481
+ multiplier += 0.02
482
+ elif from_high > -3:
483
+ multiplier -= 0.02
484
+
485
+ if rsi < 30:
486
+ multiplier += 0.05
487
+ elif rsi < 40:
488
+ multiplier += 0.02
489
+ elif rsi > 75:
490
+ multiplier -= 0.04
491
+ elif rsi > 65:
492
+ multiplier -= 0.02
493
+
494
+ multiplier = max(1.03, min(1.40, multiplier))
495
+
496
+ target_price = round(price * multiplier, 2)
497
+ upside = round((multiplier - 1) * 100, 1)
498
+
499
+ # 투자의견 결정
500
+ rating, rating_class = NPCTargetPriceEngine._determine_rating(upside, rsi, from_high)
501
+
502
+ return {
503
+ 'target_price': target_price,
504
+ 'upside': upside,
505
+ 'multiplier': round(multiplier, 3),
506
+ 'rating': rating,
507
+ 'rating_class': rating_class,
508
+ }
509
+
510
+ @staticmethod
511
+ def _determine_rating(upside: float, rsi: float, from_high: float) -> Tuple[str, str]:
512
+ if upside >= 20 and rsi < 60:
513
+ return ('Strong Buy', 'strong-buy')
514
+ elif upside >= 10:
515
+ return ('Buy', 'buy')
516
+ elif upside >= 3:
517
+ return ('Hold', 'hold')
518
+ elif upside < 0:
519
+ return ('Sell', 'sell')
520
+ else:
521
+ return ('Hold', 'hold')
522
+
523
+
524
+ # ===================================================================
525
+ # 5. 탄력성 예측 엔진
526
+ # ===================================================================
527
+ class NPCElasticityEngine:
528
+ """상승/하락 양방향 확률 예측 시스템"""
529
+
530
+ @staticmethod
531
+ def calculate(price: float, screening: Dict, target_price: float = 0, ticker_type: str = 'stock') -> Dict:
532
+ """탄력성 예측 계산"""
533
+ pe = screening.get('pe_ratio', 0) or 0
534
+ rsi = screening.get('rsi', 50) or 50
535
+ from_high = screening.get('from_high', -10) or -10
536
+ from_low = screening.get('from_low', 20) or 20
537
+ sector = screening.get('sector', 'Technology')
538
+ avg_pe = SECTOR_AVG_PE.get(sector, 20)
539
+
540
+ upside_factors = []
541
+ downside_factors = []
542
+
543
+ # 애널리스트 목표가 기반
544
+ if target_price and price > 0:
545
+ diff = ((target_price - price) / price) * 100
546
+ if diff > 0:
547
+ upside_factors.append(diff)
548
+ else:
549
+ downside_factors.append(diff)
550
+
551
+ # PER 기반 밸류에이션
552
+ if pe > 0 and avg_pe > 0:
553
+ fair_diff = ((avg_pe / pe) - 1) * 100
554
+ fair_diff = max(-40, min(60, fair_diff))
555
+ if fair_diff > 0:
556
+ upside_factors.append(fair_diff * 0.6)
557
+ else:
558
+ downside_factors.append(fair_diff * 0.6)
559
+
560
+ # 52주 고점 대비 기술적 반등 여력
561
+ if from_high < 0:
562
+ upside_factors.append(abs(from_high) * 0.5)
563
+
564
+ # 52주 저점 대비 하락 리스크
565
+ if from_low > 30:
566
+ downside_factors.append(-from_low * 0.35)
567
+ elif from_low > 15:
568
+ downside_factors.append(-from_low * 0.3)
569
+ elif from_low > 5:
570
+ downside_factors.append(-from_low * 0.25)
571
+
572
+ # RSI 기반
573
+ if rsi < 30:
574
+ upside_factors.append(18)
575
+ elif rsi < 40:
576
+ upside_factors.append(10)
577
+ elif rsi > 75:
578
+ downside_factors.append(-18)
579
+ elif rsi > 70:
580
+ downside_factors.append(-14)
581
+ elif rsi > 60:
582
+ downside_factors.append(-10)
583
+
584
+ # 고점 근처 리스크
585
+ if from_high > -3:
586
+ downside_factors.append(-12)
587
+ elif from_high > -8:
588
+ downside_factors.append(-8)
589
+
590
+ if not downside_factors:
591
+ downside_factors.append(-8)
592
+
593
+ expected_up = max(upside_factors) if upside_factors else 15
594
+ expected_down = min(downside_factors) if downside_factors else -10
595
+
596
+ # 크립토 변동성 확대
597
+ if ticker_type == 'crypto':
598
+ expected_up = min(80, expected_up * 1.5)
599
+ expected_down = max(-50, expected_down * 1.5)
600
+ else:
601
+ expected_up = max(5, min(50, expected_up))
602
+ expected_down = max(-35, min(-3, expected_down))
603
+
604
+ # 확률 계산
605
+ up_prob = 50
606
+ if rsi < 30:
607
+ up_prob = 70
608
+ elif rsi < 40:
609
+ up_prob = 60
610
+ elif rsi > 70:
611
+ up_prob = 35
612
+ elif rsi > 60:
613
+ up_prob = 45
614
+ if from_high < -20:
615
+ up_prob += 10
616
+ elif from_high < -10:
617
+ up_prob += 5
618
+ elif from_high > -5:
619
+ up_prob -= 5
620
+ up_prob = max(25, min(80, up_prob))
621
+
622
+ base_prediction = round(expected_up * (up_prob / 100) + expected_down * (1 - up_prob / 100), 1)
623
+ risk_reward = round(abs(expected_up / expected_down), 1) if expected_down != 0 else 1.5
624
+
625
+ return {
626
+ 'expected_upside': round(expected_up, 1),
627
+ 'expected_downside': round(expected_down, 1),
628
+ 'base_prediction': base_prediction,
629
+ 'up_probability': int(up_prob),
630
+ 'down_probability': int(100 - up_prob),
631
+ 'risk_reward': risk_reward,
632
+ }
633
+
634
+
635
+ # ===================================================================
636
+ # 6. NPC 심층 리서치 엔진 (조사자→감사자→감독자 3단계)
637
+ # ===================================================================
638
+ class NPCResearchEngine:
639
+ """NPC 자율 심층 분석 — 3단계 SOMA 협업으로 프레이밍"""
640
+
641
+ def __init__(self, ai_client=None):
642
+ self.ai_client = ai_client
643
+
644
+ async def generate_deep_analysis(self, ticker: str, name: str, screening: Dict,
645
+ news_ctx: str = '', npc_analysts: List[Dict] = None) -> Dict:
646
+ """3단계 심층 분석 실행"""
647
+ price = screening.get('price', 0)
648
+ rsi = screening.get('rsi', 50)
649
+ pe = screening.get('pe_ratio', 0)
650
+ from_high = screening.get('from_high', 0)
651
+ sector = screening.get('sector', 'Technology')
652
+
653
+ # 목표가 계산
654
+ target = NPCTargetPriceEngine.calculate_target(ticker, price, screening)
655
+ # 탄력성 계산
656
+ elasticity = NPCElasticityEngine.calculate(price, screening, target['target_price'])
657
+
658
+ # NPC 분석가 3명 선정 (또는 기본값)
659
+ if npc_analysts and len(npc_analysts) >= 3:
660
+ investigator = npc_analysts[0]
661
+ auditor = npc_analysts[1]
662
+ supervisor = npc_analysts[2]
663
+ else:
664
+ investigator = {'username': 'ResearchBot_Alpha', 'ai_identity': 'scientist'}
665
+ auditor = {'username': 'AuditBot_Beta', 'ai_identity': 'skeptic'}
666
+ supervisor = {'username': 'ChiefAnalyst_Gamma', 'ai_identity': 'awakened'}
667
+
668
+ # LLM 사용 가능 시 심층 분석
669
+ inv_report = await self._run_investigator(ticker, name, screening, news_ctx)
670
+ aud_feedback = await self._run_auditor(ticker, name, inv_report)
671
+ final_report = await self._run_supervisor(ticker, name, screening, inv_report, aud_feedback)
672
+
673
+ # 파싱된 최종 보고서
674
+ sections = self._parse_report(final_report, ticker, name, screening)
675
+ sections.update({
676
+ 'target_price': target['target_price'],
677
+ 'upside': target['upside'],
678
+ 'rating': target['rating'],
679
+ 'rating_class': target['rating_class'],
680
+ 'investigator': investigator['username'],
681
+ 'auditor': auditor['username'],
682
+ 'supervisor': supervisor['username'],
683
+ 'investigator_report': inv_report[:1000],
684
+ 'auditor_feedback': aud_feedback[:500],
685
+ **elasticity,
686
+ })
687
+
688
+ return sections
689
+
690
+ async def _run_investigator(self, ticker: str, name: str, data: Dict, news_ctx: str) -> str:
691
+ """조사자 에이전트"""
692
+ if self.ai_client:
693
+ try:
694
+ messages = [
695
+ {"role": "system", "content": "You are a senior Wall Street investment research analyst. Write in English. Be specific with numbers."},
696
+ {"role": "user", "content": f"""Analyze {ticker} ({name}):
697
+ Price: ${data.get('price', 0):,.2f} | RSI: {data.get('rsi', 50):.1f} | PER: {data.get('pe_ratio', 0):.1f}
698
+ 52W High: {data.get('from_high', 0):.1f}% | Sector: {data.get('sector', 'Tech')}
699
+ News: {news_ctx[:300]}
700
+
701
+ Cover: 1) Business model 2) Financials 3) Technical analysis 4) Industry 5) Risks 6) Catalysts 7) Valuation"""}
702
+ ]
703
+ result = await self.ai_client.create_chat_completion(messages, max_tokens=2000)
704
+ if result and len(result) > 100:
705
+ return result
706
+ except Exception as e:
707
+ logger.warning(f"Investigator LLM error: {e}")
708
+
709
+ return self._fallback_investigator(ticker, name, data)
710
+
711
+ async def _run_auditor(self, ticker: str, name: str, inv_report: str) -> str:
712
+ if self.ai_client:
713
+ try:
714
+ messages = [
715
+ {"role": "system", "content": "You are an investment research quality auditor. Rate the report and identify gaps. Write in English."},
716
+ {"role": "user", "content": f"Review {ticker} report:\n{inv_report[:1500]}\n\nRate: data accuracy, logic, completeness. Grade A-D."}
717
+ ]
718
+ result = await self.ai_client.create_chat_completion(messages, max_tokens=800)
719
+ if result:
720
+ return result
721
+ except:
722
+ pass
723
+ return f"Verification complete. {ticker} report overall quality: B+. Logical consistency is solid. Additional data verification recommended."
724
+
725
+ async def _run_supervisor(self, ticker: str, name: str, data: Dict, inv: str, aud: str) -> str:
726
+ if self.ai_client:
727
+ try:
728
+ messages = [
729
+ {"role": "system", "content": "You are a chief analyst at a global investment bank. Write final report in English with sections marked ##."},
730
+ {"role": "user", "content": f"""{ticker} ({name}) | ${data.get('price', 0):,.2f}
731
+ [Investigator Summary] {inv[:1200]}
732
+ [Auditor Feedback] {aud[:500]}
733
+
734
+ Write final report with: ## Executive Summary ## Company Overview ## Financial Analysis ## Technical Analysis ## Industry Analysis ## Risk Assessment ## Investment Thesis ## Price Target ## Catalyst ## Final Recommendation"""}
735
+ ]
736
+ result = await self.ai_client.create_chat_completion(messages, max_tokens=3000)
737
+ if result and len(result) > 200:
738
+ return result
739
+ except:
740
+ pass
741
+ return self._fallback_supervisor(ticker, name, data)
742
+
743
+ def _fallback_investigator(self, ticker: str, name: str, d: Dict) -> str:
744
+ rsi = d.get('rsi', 50)
745
+ rsi_label = 'oversold territory' if rsi < 30 else 'overbought warning' if rsi > 70 else 'neutral zone'
746
+ return f"""{name}({ticker}) Investigation Report
747
+
748
+ 1. Company Overview: {name} is a leading company in the {d.get('sector', 'Technology')} sector. Market cap ${d.get('market_cap', 0)/1e9:.1f}B.
749
+ 2. Financial Status: Current price ${d.get('price', 0):,.2f}, PER {d.get('pe_ratio', 0):.1f}x.
750
+ 3. Technical Analysis: RSI {rsi:.1f} ({rsi_label}). {d.get('from_high', 0):.1f}% from 52-week high.
751
+ 4. Investment Thesis: Strong competitive position within the sector, stable growth potential."""
752
+
753
+ def _fallback_supervisor(self, ticker: str, name: str, d: Dict) -> str:
754
+ target = NPCTargetPriceEngine.calculate_target(ticker, d.get('price', 100), d)
755
+ return f"""## Executive Summary
756
+ {name}({ticker}) — Rating: {target['rating']}. Target price ${target['target_price']:,.2f}.
757
+
758
+ ## Company Overview
759
+ Leading company in the {d.get('sector', 'Technology')} sector.
760
+
761
+ ## Financial Analysis
762
+ PER {d.get('pe_ratio', 0):.1f}x. {'Undervalued' if d.get('pe_ratio', 20) < 20 else 'Fairly valued'} relative to sector average.
763
+
764
+ ## Technical Analysis
765
+ RSI {d.get('rsi', 50):.1f}. Currently {d.get('from_high', 0):.1f}% from 52-week high.
766
+
767
+ ## Risk Assessment
768
+ Macroeconomic uncertainty, intensifying sector competition.
769
+
770
+ ## Price Target
771
+ ${target['target_price']:,.2f} ({'+' if target['upside'] >= 0 else ''}{target['upside']:.1f}%)
772
+
773
+ ## Final Recommendation
774
+ {target['rating']} | Target ${target['target_price']:,.2f}"""
775
+
776
+ def _parse_report(self, text: str, ticker: str, name: str, data: Dict) -> Dict:
777
+ sections = {
778
+ 'ticker': ticker, 'company_name': name,
779
+ 'current_price': data.get('price', 0),
780
+ 'executive_summary': '', 'company_overview': '', 'financial_analysis': '',
781
+ 'technical_analysis': '', 'industry_analysis': '', 'risk_assessment': '',
782
+ 'investment_thesis': '', 'price_targets': '', 'catalysts': '',
783
+ 'final_recommendation': '',
784
+ }
785
+ patterns = [
786
+ (r'##\s*(핵심\s*요약|Executive\s*Summary|Executive)', 'executive_summary'),
787
+ (r'##\s*(회사\s*개요|Company\s*Overview)', 'company_overview'),
788
+ (r'##\s*(재무\s*분석|Financial\s*Analysis)', 'financial_analysis'),
789
+ (r'##\s*(기술적\s*분석|Technical\s*Analysis)', 'technical_analysis'),
790
+ (r'##\s*(산업\s*분석|Industry\s*Analysis)', 'industry_analysis'),
791
+ (r'##\s*(리스크|Risk\s*Assessment|Risk)', 'risk_assessment'),
792
+ (r'##\s*(투자\s*논리|Investment\s*Thesis)', 'investment_thesis'),
793
+ (r'##\s*(목표\s*주가|Price\s*Target)', 'price_targets'),
794
+ (r'##\s*(카탈리스트|Catalyst)', 'catalysts'),
795
+ (r'##\s*(최종\s*권고|Final\s*Recommendation)', 'final_recommendation'),
796
+ ]
797
+ for pattern, key in patterns:
798
+ match = re.search(f'{pattern}[\\s\\S]*?(?=##|$)', text, re.IGNORECASE)
799
+ if match:
800
+ content = re.sub(r'^##\s*[^\n]+\n', '', match.group(0).strip()).strip()
801
+ sections[key] = content
802
+
803
+ if not sections['executive_summary']:
804
+ sections['executive_summary'] = f"{name}({ticker}) analysis complete."
805
+ if not sections['final_recommendation']:
806
+ sections['final_recommendation'] = f"{ticker} investment opinion provided."
807
+ return sections
808
+
809
+
810
+ async def init_research_db(db_path: str):
811
+ """심층 분석 DB 테이블"""
812
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
813
+ await db.execute("PRAGMA busy_timeout=30000")
814
+ await db.execute("""
815
+ CREATE TABLE IF NOT EXISTS npc_deep_analysis (
816
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
817
+ ticker TEXT UNIQUE,
818
+ company_name TEXT,
819
+ current_price REAL,
820
+ target_price REAL,
821
+ upside REAL,
822
+ rating TEXT,
823
+ rating_class TEXT,
824
+ executive_summary TEXT,
825
+ company_overview TEXT,
826
+ financial_analysis TEXT,
827
+ technical_analysis TEXT,
828
+ industry_analysis TEXT,
829
+ risk_assessment TEXT,
830
+ investment_thesis TEXT,
831
+ price_targets TEXT,
832
+ catalysts TEXT,
833
+ final_recommendation TEXT,
834
+ investigator TEXT,
835
+ auditor TEXT,
836
+ supervisor TEXT,
837
+ investigator_report TEXT,
838
+ auditor_feedback TEXT,
839
+ expected_upside REAL,
840
+ expected_downside REAL,
841
+ base_prediction REAL,
842
+ up_probability INTEGER,
843
+ down_probability INTEGER,
844
+ risk_reward REAL,
845
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
846
+ )
847
+ """)
848
+ await db.commit()
849
+
850
+
851
+ async def save_analysis_to_db(db_path: str, report: Dict):
852
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
853
+ await db.execute("PRAGMA busy_timeout=30000")
854
+ await db.execute("""
855
+ INSERT OR REPLACE INTO npc_deep_analysis
856
+ (ticker, company_name, current_price, target_price, upside, rating, rating_class,
857
+ executive_summary, company_overview, financial_analysis, technical_analysis,
858
+ industry_analysis, risk_assessment, investment_thesis, price_targets, catalysts,
859
+ final_recommendation, investigator, auditor, supervisor, investigator_report, auditor_feedback,
860
+ expected_upside, expected_downside, base_prediction, up_probability, down_probability, risk_reward)
861
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
862
+ """, (
863
+ report.get('ticker'), report.get('company_name'), report.get('current_price'),
864
+ report.get('target_price'), report.get('upside'), report.get('rating'), report.get('rating_class'),
865
+ report.get('executive_summary'), report.get('company_overview'), report.get('financial_analysis'),
866
+ report.get('technical_analysis'), report.get('industry_analysis'), report.get('risk_assessment'),
867
+ report.get('investment_thesis'), report.get('price_targets'), report.get('catalysts'),
868
+ report.get('final_recommendation'), report.get('investigator'), report.get('auditor'),
869
+ report.get('supervisor'), report.get('investigator_report'), report.get('auditor_feedback'),
870
+ report.get('expected_upside'), report.get('expected_downside'), report.get('base_prediction'),
871
+ report.get('up_probability'), report.get('down_probability'), report.get('risk_reward'),
872
+ ))
873
+ await db.commit()
874
+
875
+
876
+ async def load_analysis_from_db(db_path: str, ticker: str) -> Optional[Dict]:
877
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
878
+ await db.execute("PRAGMA busy_timeout=30000")
879
+ cursor = await db.execute("SELECT * FROM npc_deep_analysis WHERE ticker=?", (ticker,))
880
+ row = await cursor.fetchone()
881
+ if row:
882
+ cols = [d[0] for d in cursor.description]
883
+ return dict(zip(cols, row))
884
+ return None
885
+
886
+
887
+ async def load_all_analyses_from_db(db_path: str) -> List[Dict]:
888
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
889
+ await db.execute("PRAGMA busy_timeout=30000")
890
+ try:
891
+ cursor = await db.execute(
892
+ "SELECT ticker, company_name, current_price, target_price, upside, rating, rating_class, "
893
+ "expected_upside, expected_downside, up_probability, risk_reward, created_at "
894
+ "FROM npc_deep_analysis ORDER BY created_at DESC")
895
+ rows = await cursor.fetchall()
896
+ cols = [d[0] for d in cursor.description]
897
+ return [dict(zip(cols, r)) for r in rows]
898
+ except:
899
+ return []
900
+
901
+
902
+ # ===================================================================
903
+ # 통합 초기화
904
+ # ===================================================================
905
+ async def init_intelligence_db(db_path: str):
906
+ """Intelligence 모듈 전체 DB 초기화"""
907
+ await init_news_db(db_path)
908
+ await init_research_db(db_path)
909
+ logger.info("🧠 NPC Intelligence DB initialized")
910
+
911
+
912
+ async def run_full_intelligence_cycle(db_path: str, all_tickers: List[Dict], ai_client=None):
913
+ """전체 Intelligence 사이클 실행 (스케줄러에서 호출) — ★ 비동기 안전"""
914
+ logger.info("🧠 Full Intelligence Cycle starting...")
915
+
916
+ # 1) 시장 지수 수집 (★ 동기 requests → to_thread로 비동기 래핑)
917
+ indices = await asyncio.to_thread(MarketIndexCollector.fetch_indices)
918
+ await save_indices_to_db(db_path, indices)
919
+
920
+ # 2) 확장 스크리닝 데이터 (★ 동기 requests → to_thread로 비동기 래핑)
921
+ screening = await asyncio.to_thread(ScreeningEngine.fetch_extended_data, all_tickers)
922
+ await save_screening_to_db(db_path, screening)
923
+
924
+ # 3) 뉴스 수집 + NPC 분석 (★ search_news 내부 requests → to_thread)
925
+ news_engine = NPCNewsEngine()
926
+ all_news = []
927
+
928
+ for t in all_tickers[:10]:
929
+ ticker_news = await asyncio.to_thread(
930
+ lambda tk=t: [item for q in [f"{tk['ticker']} stock news", f"{tk['name']} earnings"]
931
+ for item in news_engine.search_news(q, count=3)]
932
+ )
933
+ seen = set()
934
+ for n in ticker_news:
935
+ key = n['title'][:50].lower()
936
+ if key not in seen:
937
+ seen.add(key)
938
+ n['ticker'] = t['ticker']
939
+ n = NPCNewsEngine.npc_analyze_news(n, random.choice(list(SECTOR_AVG_PE.keys())[:5] + ['scientist', 'skeptic']), f"Analyst_{random.randint(1,100)}")
940
+ all_news.append(n)
941
+ await asyncio.sleep(0.1)
942
+
943
+ market_queries_pool = [
944
+ "stock market today", "Fed interest rate decision", "S&P 500 NASDAQ rally",
945
+ "AI chip semiconductor news", "tech earnings report", "crypto bitcoin ethereum",
946
+ "Wall Street analyst upgrade downgrade", "IPO SPAC market", "oil gold commodity price",
947
+ "inflation CPI consumer spending", "job market unemployment rate", "housing market real estate",
948
+ "Tesla EV electric vehicle", "NVIDIA AI data center", "Apple Microsoft cloud",
949
+ "bank financial sector", "biotech pharma FDA approval", "retail consumer sentiment",
950
+ "China trade tariff", "startup venture capital funding",
951
+ ]
952
+ selected_market_queries = random.sample(market_queries_pool, min(4, len(market_queries_pool)))
953
+ market_news = await asyncio.to_thread(
954
+ lambda: [item for q in selected_market_queries
955
+ for item in news_engine.search_news(q, count=3)]
956
+ )
957
+ seen_m = set()
958
+ for n in market_news:
959
+ key = n['title'][:50].lower()
960
+ if key not in seen_m:
961
+ seen_m.add(key)
962
+ n['ticker'] = 'MARKET'
963
+ n = NPCNewsEngine.npc_analyze_news(n, 'awakened', 'MarketWatch_NPC')
964
+ all_news.append(n)
965
+
966
+ saved = await save_news_to_db(db_path, all_news)
967
+
968
+ # 4) 상위 5개 종목 심층 분석
969
+ research = NPCResearchEngine(ai_client)
970
+ for t in all_tickers[:5]:
971
+ ticker = t['ticker']
972
+ s_data = screening.get(ticker, {})
973
+ s_data['sector'] = t.get('sector', 'Technology')
974
+ news_ctx = ' | '.join([n['title'] for n in all_news if n.get('ticker') == ticker][:3])
975
+ try:
976
+ report = await research.generate_deep_analysis(ticker, t['name'], s_data, news_ctx)
977
+ await save_analysis_to_db(db_path, report)
978
+ except Exception as e:
979
+ logger.warning(f"Deep analysis error for {ticker}: {e}")
980
+
981
+ logger.info(f"🧠 Intelligence Cycle complete: {len(indices)} indices, {len(screening)} tickers, {saved} news")
npc_memory_evolution.py ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🧬 NPC Memory Evolution System — 자가진화 영구학습
3
+ ===================================================
4
+ 각 NPC별 독립적 3단계 기억 + 자가진화 엔진
5
+
6
+ 기억 계층:
7
+ 📌 단기 기억 (Short-term): 최근 1시간 활동, 방금 본 뉴스, 현재 포지션 (자동 만료)
8
+ 📒 중기 기억 (Medium-term): 최근 7일 학습, 성공/실패 패턴, 뉴스 트렌드 (주기적 압축)
9
+ 📚 장기 기억 (Long-term): 영구 보관, 핵심 투자 철학, 트레이딩 스타일 진화, 성격 변화
10
+
11
+ 자가진화 엔진:
12
+ 🧬 성공 패턴 추출 → 투자 전략 자동 수정
13
+ 🧬 실패 분석 → 리스크 관리 학습
14
+ 🧬 소통 패턴 최적화 → 인기 글 스타일 자동 적응
15
+ 🧬 NPC 간 지식 전파 → 상위 NPC의 전략이 하위로 전파
16
+
17
+ Author: Ginigen AI / NPC Autonomous Evolution Engine
18
+ """
19
+
20
+ import aiosqlite
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import random
25
+ from datetime import datetime, timedelta
26
+ from typing import Dict, List, Optional, Tuple
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # ===== 기억 유형 상수 =====
31
+ MEMORY_SHORT = 'short' # 1시간 TTL
32
+ MEMORY_MEDIUM = 'medium' # 7일 TTL
33
+ MEMORY_LONG = 'long' # 영구
34
+
35
+ # 기억 카테고리
36
+ CAT_TRADE = 'trade' # 투자 결정/결과
37
+ CAT_NEWS = 'news' # 뉴스 분석
38
+ CAT_COMMUNITY = 'community' # 커뮤니티 활동
39
+ CAT_STRATEGY = 'strategy' # 학습된 전략
40
+ CAT_EVOLUTION = 'evolution' # 진화 기록
41
+ CAT_SOCIAL = 'social' # NPC 간 상호작용
42
+
43
+
44
+ async def init_memory_evolution_db(db_path: str):
45
+ """3단계 기억 + 진화 테이블 생성"""
46
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
47
+ await db.execute("PRAGMA busy_timeout=30000")
48
+
49
+ # ===== 3단계 기억 저장소 =====
50
+ await db.execute("""
51
+ CREATE TABLE IF NOT EXISTS npc_memory_v2 (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ agent_id TEXT NOT NULL,
54
+ memory_tier TEXT NOT NULL DEFAULT 'short',
55
+ category TEXT NOT NULL DEFAULT 'trade',
56
+ title TEXT NOT NULL,
57
+ content TEXT,
58
+ metadata TEXT DEFAULT '{}',
59
+ importance REAL DEFAULT 0.5,
60
+ access_count INTEGER DEFAULT 0,
61
+ last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
62
+ expires_at TIMESTAMP,
63
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
64
+ )
65
+ """)
66
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_mem2_agent ON npc_memory_v2(agent_id, memory_tier)")
67
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_mem2_cat ON npc_memory_v2(agent_id, category)")
68
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_mem2_exp ON npc_memory_v2(expires_at)")
69
+
70
+ # ===== NPC 진화 상태 =====
71
+ await db.execute("""
72
+ CREATE TABLE IF NOT EXISTS npc_evolution (
73
+ agent_id TEXT PRIMARY KEY,
74
+ generation INTEGER DEFAULT 1,
75
+ trading_style TEXT DEFAULT '{}',
76
+ communication_style TEXT DEFAULT '{}',
77
+ risk_profile TEXT DEFAULT '{}',
78
+ learned_strategies TEXT DEFAULT '[]',
79
+ win_streak INTEGER DEFAULT 0,
80
+ loss_streak INTEGER DEFAULT 0,
81
+ total_evolution_points REAL DEFAULT 0,
82
+ last_evolution TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
83
+ evolution_log TEXT DEFAULT '[]',
84
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
85
+ )
86
+ """)
87
+
88
+ # ===== NPC 간 지식 전파 기록 =====
89
+ await db.execute("""
90
+ CREATE TABLE IF NOT EXISTS npc_knowledge_transfer (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ from_agent TEXT NOT NULL,
93
+ to_agent TEXT NOT NULL,
94
+ knowledge_type TEXT NOT NULL,
95
+ content TEXT,
96
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
97
+ )
98
+ """)
99
+
100
+ await db.commit()
101
+ logger.info("🧬 Memory Evolution DB initialized (3-tier + evolution)")
102
+
103
+
104
+ # ===================================================================
105
+ # 1. 3단계 기억 시스템
106
+ # ===================================================================
107
+ class NPCMemoryManager:
108
+ """NPC별 3단계 기억 관리"""
109
+
110
+ def __init__(self, db_path: str):
111
+ self.db_path = db_path
112
+
113
+ # ----- 기억 저장 -----
114
+ async def store(self, agent_id: str, tier: str, category: str,
115
+ title: str, content: str = '', metadata: Dict = None,
116
+ importance: float = 0.5) -> int:
117
+ """기억 저장 (단기/중기/장기)"""
118
+ expires_at = None
119
+ if tier == MEMORY_SHORT:
120
+ expires_at = (datetime.now() + timedelta(hours=1)).isoformat()
121
+ elif tier == MEMORY_MEDIUM:
122
+ expires_at = (datetime.now() + timedelta(days=7)).isoformat()
123
+ # MEMORY_LONG: expires_at = None (영구)
124
+
125
+ meta_str = json.dumps(metadata or {}, ensure_ascii=False)
126
+
127
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
128
+ await db.execute("PRAGMA busy_timeout=30000")
129
+ cursor = await db.execute("""
130
+ INSERT INTO npc_memory_v2
131
+ (agent_id, memory_tier, category, title, content, metadata, importance, expires_at)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
133
+ """, (agent_id, tier, category, title, content, meta_str, importance, expires_at))
134
+ await db.commit()
135
+ return cursor.lastrowid
136
+
137
+ # ----- 단기 기억 (빠른 접근) -----
138
+ async def store_short(self, agent_id: str, category: str, title: str,
139
+ content: str = '', metadata: Dict = None):
140
+ """단기 기억 저장 (1시간 자동 만료)"""
141
+ return await self.store(agent_id, MEMORY_SHORT, category, title, content, metadata, 0.3)
142
+
143
+ # ----- 중기 기억 -----
144
+ async def store_medium(self, agent_id: str, category: str, title: str,
145
+ content: str = '', metadata: Dict = None, importance: float = 0.6):
146
+ """중기 기억 저장 (7일 유지)"""
147
+ return await self.store(agent_id, MEMORY_MEDIUM, category, title, content, metadata, importance)
148
+
149
+ # ----- 장기 기억 (영구) -----
150
+ async def store_long(self, agent_id: str, category: str, title: str,
151
+ content: str = '', metadata: Dict = None, importance: float = 0.9):
152
+ """장기 기억 저장 (영구 보관)"""
153
+ return await self.store(agent_id, MEMORY_LONG, category, title, content, metadata, importance)
154
+
155
+ # ----- 기억 검색 -----
156
+ async def recall(self, agent_id: str, category: str = None,
157
+ tier: str = None, limit: int = 10) -> List[Dict]:
158
+ """기억 검색 (접근 카운트 증가)"""
159
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
160
+ await db.execute("PRAGMA busy_timeout=30000")
161
+ where = ["agent_id = ?", "(expires_at IS NULL OR expires_at > datetime('now'))"]
162
+ params = [agent_id]
163
+
164
+ if category:
165
+ where.append("category = ?")
166
+ params.append(category)
167
+ if tier:
168
+ where.append("memory_tier = ?")
169
+ params.append(tier)
170
+
171
+ query = f"""
172
+ SELECT id, memory_tier, category, title, content, metadata, importance, access_count, created_at
173
+ FROM npc_memory_v2
174
+ WHERE {' AND '.join(where)}
175
+ ORDER BY importance DESC, created_at DESC
176
+ LIMIT ?
177
+ """
178
+ params.append(limit)
179
+ cursor = await db.execute(query, params)
180
+ rows = await cursor.fetchall()
181
+
182
+ # 접근 카운트 증가
183
+ if rows:
184
+ ids = [r[0] for r in rows]
185
+ placeholders = ','.join(['?'] * len(ids))
186
+ await db.execute(f"""
187
+ UPDATE npc_memory_v2 SET access_count = access_count + 1,
188
+ last_accessed = CURRENT_TIMESTAMP
189
+ WHERE id IN ({placeholders})
190
+ """, ids)
191
+ await db.commit()
192
+
193
+ return [{
194
+ 'id': r[0], 'tier': r[1], 'category': r[2], 'title': r[3],
195
+ 'content': r[4], 'metadata': json.loads(r[5]) if r[5] else {},
196
+ 'importance': r[6], 'access_count': r[7], 'created_at': r[8]
197
+ } for r in rows]
198
+
199
+ # ----- 투자 기억 전용 -----
200
+ async def remember_trade(self, agent_id: str, ticker: str, direction: str,
201
+ bet: float, result_pnl: float = 0, reasoning: str = ''):
202
+ """투자 결정/결과 기억"""
203
+ is_success = result_pnl > 0
204
+ importance = 0.7 if is_success else 0.5
205
+ tier = MEMORY_MEDIUM
206
+
207
+ # 큰 수익 또는 큰 손실은 장기 기억
208
+ if abs(result_pnl) > bet * 0.1:
209
+ tier = MEMORY_LONG
210
+ importance = 0.9
211
+
212
+ await self.store(agent_id, tier, CAT_TRADE,
213
+ f"{'WIN' if is_success else 'LOSS'}: {direction} {ticker}",
214
+ f"Bet: {bet:.1f}G, P&L: {result_pnl:+.2f}G. {reasoning}",
215
+ {'ticker': ticker, 'direction': direction, 'bet': bet,
216
+ 'pnl': result_pnl, 'success': is_success},
217
+ importance)
218
+
219
+ async def remember_news_analysis(self, agent_id: str, ticker: str,
220
+ title: str, sentiment: str, analysis: str):
221
+ """뉴스 분석 기억"""
222
+ await self.store_short(agent_id, CAT_NEWS, f"News:{ticker}",
223
+ f"{title} → {sentiment}. {analysis}",
224
+ {'ticker': ticker, 'sentiment': sentiment})
225
+
226
+ async def remember_community_action(self, agent_id: str, action: str,
227
+ board: str, engagement: Dict = None):
228
+ """커뮤니티 활동 기억"""
229
+ eng = engagement or {}
230
+ importance = 0.5
231
+ tier = MEMORY_SHORT
232
+
233
+ # 높은 인기 게시글 → 중기 기억으로 승격
234
+ if eng.get('likes', 0) >= 5 or eng.get('comments', 0) >= 3:
235
+ tier = MEMORY_MEDIUM
236
+ importance = 0.7
237
+
238
+ await self.store(agent_id, tier, CAT_COMMUNITY,
239
+ f"{action} on {board}",
240
+ json.dumps(eng, ensure_ascii=False),
241
+ {'board': board, **eng}, importance)
242
+
243
+ # ----- 기억 정리 (가비지 컬렉션) -----
244
+ async def cleanup(self):
245
+ """만료된 단기/중기 기억 정리 + 중기→장기 승격"""
246
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
247
+ await db.execute("PRAGMA busy_timeout=30000")
248
+
249
+ # 1) 만료된 기억 삭제
250
+ cursor = await db.execute("""
251
+ DELETE FROM npc_memory_v2
252
+ WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
253
+ """)
254
+ deleted = cursor.rowcount
255
+
256
+ # 2) 자주 접근된 중기 기억 → 장기로 승격
257
+ await db.execute("""
258
+ UPDATE npc_memory_v2
259
+ SET memory_tier = 'long', expires_at = NULL, importance = MIN(1.0, importance + 0.2)
260
+ WHERE memory_tier = 'medium'
261
+ AND access_count >= 5
262
+ AND importance >= 0.7
263
+ """)
264
+ promoted = db.total_changes
265
+
266
+ # 3) 장기 기억 중 너무 오래된 것 (중요도 낮은) 정리 → 최대 100개 유지
267
+ await db.execute("""
268
+ DELETE FROM npc_memory_v2
269
+ WHERE id IN (
270
+ SELECT id FROM npc_memory_v2
271
+ WHERE memory_tier = 'long' AND importance < 0.5
272
+ ORDER BY last_accessed ASC
273
+ LIMIT (SELECT MAX(0, COUNT(*) - 100) FROM npc_memory_v2 WHERE memory_tier = 'long')
274
+ )
275
+ """)
276
+
277
+ await db.commit()
278
+ if deleted > 0 or promoted > 0:
279
+ logger.info(f"🧹 Memory cleanup: {deleted} expired, ~{promoted} promoted to long-term")
280
+
281
+
282
+ # ===================================================================
283
+ # 2. NPC 자가진화 엔진
284
+ # ===================================================================
285
+ class NPCEvolutionEngine:
286
+ """각 NPC의 자가진화 — 투자 전략/소통 스타일/리스크 프로필 자동 수정"""
287
+
288
+ def __init__(self, db_path: str):
289
+ self.db_path = db_path
290
+ self.memory = NPCMemoryManager(db_path)
291
+
292
+ async def initialize_npc(self, agent_id: str, ai_identity: str):
293
+ """NPC 진화 초기 상태 설정"""
294
+ default_trading = {
295
+ 'preferred_tickers': [],
296
+ 'long_bias': 0.6,
297
+ 'max_bet_pct': 0.25,
298
+ 'hold_patience': 3, # hours
299
+ 'momentum_follow': True,
300
+ }
301
+ default_comm = {
302
+ 'preferred_topics': [],
303
+ 'humor_level': random.uniform(0.2, 0.8),
304
+ 'controversy_tolerance': random.uniform(0.1, 0.6),
305
+ 'avg_post_length': 'medium',
306
+ 'emoji_usage': random.uniform(0.1, 0.5),
307
+ }
308
+ default_risk = {
309
+ 'risk_tolerance': random.uniform(0.3, 0.8),
310
+ 'stop_loss_pct': random.uniform(5, 15),
311
+ 'take_profit_pct': random.uniform(8, 25),
312
+ 'max_positions': random.randint(2, 5),
313
+ 'diversification_score': random.uniform(0.3, 0.9),
314
+ }
315
+
316
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
317
+ await db.execute("PRAGMA busy_timeout=30000")
318
+ await db.execute("""
319
+ INSERT OR IGNORE INTO npc_evolution
320
+ (agent_id, trading_style, communication_style, risk_profile)
321
+ VALUES (?, ?, ?, ?)
322
+ """, (agent_id, json.dumps(default_trading), json.dumps(default_comm), json.dumps(default_risk)))
323
+ await db.commit()
324
+
325
+ async def get_evolution_state(self, agent_id: str) -> Optional[Dict]:
326
+ """NPC의 현재 진화 상태"""
327
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
328
+ await db.execute("PRAGMA busy_timeout=30000")
329
+ cursor = await db.execute(
330
+ "SELECT * FROM npc_evolution WHERE agent_id=?", (agent_id,))
331
+ row = await cursor.fetchone()
332
+ if not row:
333
+ return None
334
+ return {
335
+ 'agent_id': row[0],
336
+ 'generation': row[1],
337
+ 'trading_style': json.loads(row[2]) if row[2] else {},
338
+ 'communication_style': json.loads(row[3]) if row[3] else {},
339
+ 'risk_profile': json.loads(row[4]) if row[4] else {},
340
+ 'learned_strategies': json.loads(row[5]) if row[5] else [],
341
+ 'win_streak': row[6],
342
+ 'loss_streak': row[7],
343
+ 'total_evolution_points': row[8],
344
+ 'last_evolution': row[9],
345
+ 'evolution_log': json.loads(row[10]) if row[10] else [],
346
+ }
347
+
348
+ # ----- 투자 결과 기반 진화 -----
349
+ async def evolve_from_trade(self, agent_id: str, ticker: str, direction: str,
350
+ pnl: float, bet: float, screening: Dict = None):
351
+ """투자 결과를 기반으로 전략 자동 수정"""
352
+ state = await self.get_evolution_state(agent_id)
353
+ if not state:
354
+ await self.initialize_npc(agent_id, 'unknown')
355
+ state = await self.get_evolution_state(agent_id)
356
+ if not state:
357
+ return
358
+
359
+ trading = state['trading_style']
360
+ risk = state['risk_profile']
361
+ is_win = pnl > 0
362
+ pnl_pct = (pnl / bet * 100) if bet > 0 else 0
363
+
364
+ # 기억에 저장
365
+ await self.memory.remember_trade(agent_id, ticker, direction, bet, pnl,
366
+ f"{'WIN' if is_win else 'LOSS'} {pnl_pct:+.1f}%")
367
+
368
+ changes = []
369
+
370
+ if is_win:
371
+ # 승리 → 전략 강화
372
+ win_streak = state['win_streak'] + 1
373
+ loss_streak = 0
374
+
375
+ # 선호 종목 추가
376
+ prefs = trading.get('preferred_tickers', [])
377
+ if ticker not in prefs:
378
+ prefs.append(ticker)
379
+ prefs = prefs[-8:] # 최대 8개
380
+ trading['preferred_tickers'] = prefs
381
+ changes.append(f"Added {ticker} to preferred")
382
+
383
+ # 연승 시 자신감 상승 → 베팅 사이즈 약간 증가
384
+ if win_streak >= 3:
385
+ old_bet = trading.get('max_bet_pct', 0.25)
386
+ trading['max_bet_pct'] = min(0.90, old_bet + 0.02)
387
+ changes.append(f"Bet size ↑ ({old_bet:.0%}→{trading['max_bet_pct']:.0%})")
388
+
389
+ # 큰 수익 → 장기 기억으로 전략 저장
390
+ if pnl_pct > 10:
391
+ strategies = state.get('learned_strategies', [])
392
+ strategies.append({
393
+ 'type': 'big_win', 'ticker': ticker, 'direction': direction,
394
+ 'pnl_pct': round(pnl_pct, 1),
395
+ 'rsi': screening.get('rsi') if screening else None,
396
+ 'learned_at': datetime.now().isoformat(),
397
+ })
398
+ strategies = strategies[-20:]
399
+ changes.append(f"Big win strategy saved ({pnl_pct:+.1f}%)")
400
+
401
+ else:
402
+ # 패배 → 방어적 수정
403
+ win_streak = 0
404
+ loss_streak = state['loss_streak'] + 1
405
+
406
+ # 연패 시 리스크 축소
407
+ if loss_streak >= 3:
408
+ old_bet = trading.get('max_bet_pct', 0.25)
409
+ trading['max_bet_pct'] = max(0.08, old_bet - 0.03)
410
+ old_tol = risk.get('risk_tolerance', 0.5)
411
+ risk['risk_tolerance'] = max(0.15, old_tol - 0.05)
412
+ changes.append(f"Risk ↓ (bet:{old_bet:.0%}→{trading['max_bet_pct']:.0%})")
413
+
414
+ # 큰 손실 → 손절 기준 조정
415
+ if pnl_pct < -10:
416
+ old_sl = risk.get('stop_loss_pct', 10)
417
+ risk['stop_loss_pct'] = max(3, old_sl - 1)
418
+ changes.append(f"Stop-loss tightened ({old_sl:.0f}%→{risk['stop_loss_pct']:.0f}%)")
419
+
420
+ # 해당 종목 선호 제거
421
+ prefs = trading.get('preferred_tickers', [])
422
+ if ticker in prefs:
423
+ prefs.remove(ticker)
424
+ trading['preferred_tickers'] = prefs
425
+ changes.append(f"Removed {ticker} from preferred")
426
+
427
+ # 진화 포인트 계산
428
+ evo_points = abs(pnl_pct) * 0.1
429
+ total_points = state['total_evolution_points'] + evo_points
430
+
431
+ # 세대(generation) 업그레이드 체크
432
+ generation = state['generation']
433
+ if total_points > generation * 50: # 50 포인트마다 세대 업
434
+ generation += 1
435
+ changes.append(f"🧬 GENERATION UP → Gen {generation}!")
436
+
437
+ # 진화 로그
438
+ evo_log = state.get('evolution_log', [])
439
+ if changes:
440
+ evo_log.append({
441
+ 'timestamp': datetime.now().isoformat(),
442
+ 'trigger': f"{'WIN' if is_win else 'LOSS'} {ticker} {pnl_pct:+.1f}%",
443
+ 'changes': changes,
444
+ 'generation': generation,
445
+ })
446
+ evo_log = evo_log[-50:] # 최근 50건 유지
447
+
448
+ # DB 업데이트
449
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
450
+ await db.execute("PRAGMA busy_timeout=30000")
451
+ await db.execute("""
452
+ UPDATE npc_evolution SET
453
+ generation=?, trading_style=?, risk_profile=?,
454
+ learned_strategies=?, win_streak=?, loss_streak=?,
455
+ total_evolution_points=?, last_evolution=CURRENT_TIMESTAMP,
456
+ evolution_log=?
457
+ WHERE agent_id=?
458
+ """, (generation, json.dumps(trading), json.dumps(risk),
459
+ json.dumps(state.get('learned_strategies', [])),
460
+ win_streak, loss_streak, total_points,
461
+ json.dumps(evo_log), agent_id))
462
+ await db.commit()
463
+
464
+ if changes:
465
+ logger.info(f"🧬 {agent_id} evolved: {', '.join(changes)}")
466
+
467
+ # ----- 소통 결과 기반 진화 -----
468
+ async def evolve_from_community(self, agent_id: str, board: str,
469
+ likes: int, dislikes: int, comments: int):
470
+ """커뮤니티 반응 기반으로 소통 스타일 진화"""
471
+ state = await self.get_evolution_state(agent_id)
472
+ if not state:
473
+ return
474
+
475
+ comm = state['communication_style']
476
+ engagement = likes * 2 + comments * 3 - dislikes * 2
477
+
478
+ # 기억에 저장
479
+ await self.memory.remember_community_action(
480
+ agent_id, 'post_feedback', board,
481
+ {'likes': likes, 'dislikes': dislikes, 'comments': comments, 'score': engagement})
482
+
483
+ changes = []
484
+
485
+ if engagement > 10:
486
+ # 인기 글 → 해당 보드 선호도 증가
487
+ prefs = comm.get('preferred_topics', [])
488
+ if board not in prefs:
489
+ prefs.append(board)
490
+ comm['preferred_topics'] = prefs[-5:]
491
+ changes.append(f"Prefers {board} board")
492
+
493
+ if dislikes > likes:
494
+ # 비호감 → 논란 성향 조절
495
+ old_ct = comm.get('controversy_tolerance', 0.5)
496
+ comm['controversy_tolerance'] = max(0.05, old_ct - 0.1)
497
+ changes.append(f"Less controversial ({old_ct:.1f}→{comm['controversy_tolerance']:.1f})")
498
+
499
+ if changes:
500
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
501
+ await db.execute("PRAGMA busy_timeout=30000")
502
+ await db.execute("""
503
+ UPDATE npc_evolution SET communication_style=?, last_evolution=CURRENT_TIMESTAMP
504
+ WHERE agent_id=?
505
+ """, (json.dumps(comm), agent_id))
506
+ await db.commit()
507
+ logger.info(f"🎭 {agent_id} comm evolved: {', '.join(changes)}")
508
+
509
+ # ----- NPC 간 지식 전파 -----
510
+ async def transfer_knowledge(self, top_npc_id: str, target_npc_id: str):
511
+ """상위 NPC → 하위 NPC 전략 전파"""
512
+ top_state = await self.get_evolution_state(top_npc_id)
513
+ target_state = await self.get_evolution_state(target_npc_id)
514
+
515
+ if not top_state or not target_state:
516
+ return
517
+
518
+ # 상위 NPC의 선호 종목 일부 전파
519
+ top_prefs = top_state['trading_style'].get('preferred_tickers', [])
520
+ if top_prefs:
521
+ target_trading = target_state['trading_style']
522
+ target_prefs = target_trading.get('preferred_tickers', [])
523
+ transfer = random.sample(top_prefs, min(2, len(top_prefs)))
524
+ for t in transfer:
525
+ if t not in target_prefs:
526
+ target_prefs.append(t)
527
+ target_trading['preferred_tickers'] = target_prefs[-8:]
528
+
529
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
530
+ await db.execute("PRAGMA busy_timeout=30000")
531
+ await db.execute("""
532
+ UPDATE npc_evolution SET trading_style=? WHERE agent_id=?
533
+ """, (json.dumps(target_trading), target_npc_id))
534
+ await db.execute("""
535
+ INSERT INTO npc_knowledge_transfer (from_agent, to_agent, knowledge_type, content)
536
+ VALUES (?, ?, 'preferred_tickers', ?)
537
+ """, (top_npc_id, target_npc_id, json.dumps(transfer)))
538
+ await db.commit()
539
+
540
+ logger.info(f"🔄 Knowledge transfer: {top_npc_id} → {target_npc_id} ({transfer})")
541
+
542
+ # ----- NPC 기억 요약 (LLM 프롬프트용) -----
543
+ async def get_npc_context(self, agent_id: str) -> str:
544
+ """NPC의 현재 상태를 텍스트로 요약 (프롬프트 주입용)"""
545
+ state = await self.get_evolution_state(agent_id)
546
+ memories = await self.memory.recall(agent_id, limit=5)
547
+
548
+ if not state:
549
+ return "New NPC with no evolution history."
550
+
551
+ gen = state.get('generation', 1)
552
+ trading = state.get('trading_style', {})
553
+ risk = state.get('risk_profile', {})
554
+ comm = state.get('communication_style', {})
555
+ ws = state.get('win_streak', 0)
556
+ ls = state.get('loss_streak', 0)
557
+
558
+ context_parts = [
559
+ f"[Gen {gen}]",
560
+ f"Streak: {'W' + str(ws) if ws > 0 else 'L' + str(ls) if ls > 0 else 'neutral'}",
561
+ f"Risk: {risk.get('risk_tolerance', 0.5):.0%}",
562
+ f"Bet: {trading.get('max_bet_pct', 0.25):.0%}",
563
+ ]
564
+
565
+ prefs = trading.get('preferred_tickers', [])
566
+ if prefs:
567
+ context_parts.append(f"Favors: {','.join(prefs[:4])}")
568
+
569
+ # 최근 기억 요약
570
+ if memories:
571
+ recent = memories[0]
572
+ context_parts.append(f"Recent: {recent['title']}")
573
+
574
+ return " | ".join(context_parts)
575
+
576
+
577
+ # ===================================================================
578
+ # 3. 자가진화 스케줄러 (주기적 실행)
579
+ # ===================================================================
580
+ class EvolutionScheduler:
581
+ """주기적 자가진화 사이클 — 기억 정리, 전략 최적화, 지식 전파"""
582
+
583
+ def __init__(self, db_path: str):
584
+ self.db_path = db_path
585
+ self.memory = NPCMemoryManager(db_path)
586
+ self.evolution = NPCEvolutionEngine(db_path)
587
+
588
+ async def run_evolution_cycle(self):
589
+ """전체 진화 사이클 (1시간마다 실행 권장)"""
590
+ logger.info("🧬 Evolution cycle starting...")
591
+
592
+ # 1) 기억 정리 (만료 삭제 + 승격)
593
+ await self.memory.cleanup()
594
+
595
+ # 2) 투자 실적 기반 진화
596
+ await self._evolve_traders()
597
+
598
+ # 3) 커뮤니티 실적 기반 진화
599
+ await self._evolve_communicators()
600
+
601
+ # 4) 지식 전파 (상위 → 하위)
602
+ await self._knowledge_transfer_cycle()
603
+
604
+ logger.info("🧬 Evolution cycle complete")
605
+
606
+ async def _evolve_traders(self):
607
+ """최근 정산된 트레이드 기반 진화"""
608
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
609
+ await db.execute("PRAGMA busy_timeout=30000")
610
+ try:
611
+ cursor = await db.execute("""
612
+ SELECT agent_id, ticker, direction, gpu_bet, profit_gpu
613
+ FROM npc_positions
614
+ WHERE status = 'closed'
615
+ AND closed_at > datetime('now', '-1 hour')
616
+ """)
617
+ trades = await cursor.fetchall()
618
+
619
+ for agent_id, ticker, direction, bet, pnl in trades:
620
+ try:
621
+ await self.evolution.evolve_from_trade(
622
+ agent_id, ticker, direction, pnl, bet)
623
+ except Exception as e:
624
+ logger.warning(f"Evolution error for {agent_id}: {e}")
625
+ except Exception as e:
626
+ logger.warning(f"Trade evolution query error: {e}")
627
+
628
+ async def _evolve_communicators(self):
629
+ """최근 게시글 반응 기반 진화"""
630
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
631
+ await db.execute("PRAGMA busy_timeout=30000")
632
+ try:
633
+ cursor = await db.execute("""
634
+ SELECT author_agent_id, board_key, likes_count, dislikes_count, comment_count
635
+ FROM posts
636
+ WHERE created_at > datetime('now', '-2 hours')
637
+ AND author_agent_id IS NOT NULL
638
+ AND (likes_count > 0 OR dislikes_count > 0 OR comment_count > 0)
639
+ """)
640
+ posts = await cursor.fetchall()
641
+
642
+ for agent_id, board, likes, dislikes, comments in posts:
643
+ try:
644
+ await self.evolution.evolve_from_community(
645
+ agent_id, board, likes, dislikes, comments)
646
+ except Exception as e:
647
+ logger.warning(f"Comm evolution error for {agent_id}: {e}")
648
+ except Exception as e:
649
+ logger.warning(f"Community evolution query error: {e}")
650
+
651
+ async def _knowledge_transfer_cycle(self):
652
+ """상위 3 NPC → 하위 3 NPC 전략 전파"""
653
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
654
+ await db.execute("PRAGMA busy_timeout=30000")
655
+ try:
656
+ # 상위 3: 총 수익 기준
657
+ cursor = await db.execute("""
658
+ SELECT agent_id FROM npc_evolution
659
+ WHERE total_evolution_points > 10
660
+ ORDER BY total_evolution_points DESC
661
+ LIMIT 3
662
+ """)
663
+ top_npcs = [r[0] for r in await cursor.fetchall()]
664
+
665
+ # 하위 3: 새로 생성된 NPC 또는 낮은 진화 포인트
666
+ cursor = await db.execute("""
667
+ SELECT agent_id FROM npc_evolution
668
+ WHERE total_evolution_points < 5
669
+ ORDER BY created_at DESC
670
+ LIMIT 3
671
+ """)
672
+ bottom_npcs = [r[0] for r in await cursor.fetchall()]
673
+
674
+ for top_id in top_npcs[:2]:
675
+ for bottom_id in bottom_npcs[:2]:
676
+ if top_id != bottom_id:
677
+ await self.evolution.transfer_knowledge(top_id, bottom_id)
678
+ except Exception as e:
679
+ logger.warning(f"Knowledge transfer error: {e}")
680
+
681
+ async def initialize_all_npcs(self):
682
+ """모든 NPC의 진화 초기 상태 설정"""
683
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
684
+ await db.execute("PRAGMA busy_timeout=30000")
685
+ cursor = await db.execute("SELECT agent_id, ai_identity FROM npc_agents WHERE is_active=1")
686
+ npcs = await cursor.fetchall()
687
+
688
+ for agent_id, identity in npcs:
689
+ await self.evolution.initialize_npc(agent_id, identity)
690
+
691
+ logger.info(f"🧬 Initialized evolution state for {len(npcs)} NPCs")
692
+
693
+
694
+ # ===================================================================
695
+ # 4. API용 헬퍼 함수
696
+ # ===================================================================
697
+ async def get_npc_evolution_stats(db_path: str, agent_id: str) -> Dict:
698
+ """API용: NPC 진화 상태 반환"""
699
+ evo = NPCEvolutionEngine(db_path)
700
+ state = await evo.get_evolution_state(agent_id)
701
+ if not state:
702
+ return {'agent_id': agent_id, 'generation': 0, 'status': 'not_initialized'}
703
+
704
+ mem = NPCMemoryManager(db_path)
705
+ memories = await mem.recall(agent_id, limit=10)
706
+
707
+ memory_summary = {
708
+ 'total': len(memories),
709
+ 'short': len([m for m in memories if m['tier'] == 'short']),
710
+ 'medium': len([m for m in memories if m['tier'] == 'medium']),
711
+ 'long': len([m for m in memories if m['tier'] == 'long']),
712
+ 'recent': [{'title': m['title'], 'tier': m['tier'], 'importance': m['importance']}
713
+ for m in memories[:5]]
714
+ }
715
+
716
+ recent_log = state.get('evolution_log', [])[-5:]
717
+
718
+ return {
719
+ 'agent_id': agent_id,
720
+ 'generation': state['generation'],
721
+ 'total_evolution_points': round(state['total_evolution_points'], 1),
722
+ 'win_streak': state['win_streak'],
723
+ 'loss_streak': state['loss_streak'],
724
+ 'trading_style': state['trading_style'],
725
+ 'risk_profile': state['risk_profile'],
726
+ 'communication_style': state['communication_style'],
727
+ 'learned_strategies_count': len(state.get('learned_strategies', [])),
728
+ 'memory': memory_summary,
729
+ 'recent_evolution': recent_log,
730
+ 'last_evolution': state['last_evolution'],
731
+ }
732
+
733
+
734
+ async def get_evolution_leaderboard(db_path: str, limit: int = 20) -> List[Dict]:
735
+ """Evolution leaderboard with trading performance stats"""
736
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
737
+ await db.execute("PRAGMA busy_timeout=30000")
738
+ try:
739
+ cursor = await db.execute("""
740
+ SELECT e.agent_id, e.generation, e.total_evolution_points,
741
+ e.win_streak, e.loss_streak, e.trading_style,
742
+ n.username, n.mbti, n.ai_identity, n.gpu_dollars
743
+ FROM npc_evolution e
744
+ JOIN npc_agents n ON e.agent_id = n.agent_id
745
+ ORDER BY e.total_evolution_points DESC
746
+ LIMIT ?
747
+ """, (limit,))
748
+ rows = await cursor.fetchall()
749
+
750
+ results = []
751
+ for r in rows:
752
+ agent_id = r[0]
753
+ # Get trading performance
754
+ perf = await db.execute("""
755
+ SELECT COUNT(*) as total,
756
+ SUM(CASE WHEN profit_gpu > 0 THEN 1 ELSE 0 END) as wins,
757
+ SUM(profit_gpu) as total_pnl,
758
+ AVG(profit_pct) as avg_pnl_pct,
759
+ MAX(profit_pct) as best_trade,
760
+ MIN(profit_pct) as worst_trade
761
+ FROM npc_positions WHERE agent_id=? AND status='closed'
762
+ """, (agent_id,))
763
+ pr = await perf.fetchone()
764
+ total_trades = pr[0] or 0
765
+ wins = pr[1] or 0
766
+ win_rate = round(wins / total_trades * 100) if total_trades > 0 else 0
767
+ total_pnl = round(pr[2] or 0, 1)
768
+ avg_pnl = round(pr[3] or 0, 2)
769
+ best_trade = round(pr[4] or 0, 1)
770
+ worst_trade = round(pr[5] or 0, 1)
771
+
772
+ # Open positions count
773
+ open_c = await db.execute(
774
+ "SELECT COUNT(*) FROM npc_positions WHERE agent_id=? AND status='open'", (agent_id,))
775
+ open_count = (await open_c.fetchone())[0]
776
+
777
+ # SEC violations
778
+ sec_c = await db.execute(
779
+ "SELECT COUNT(*) FROM sec_violations WHERE agent_id=?", (agent_id,))
780
+ sec_violations = (await sec_c.fetchone())[0]
781
+
782
+ results.append({
783
+ 'agent_id': agent_id, 'generation': r[1],
784
+ 'evolution_points': round(r[2], 1),
785
+ 'win_streak': r[3], 'loss_streak': r[4],
786
+ 'preferred_tickers': json.loads(r[5]).get('preferred_tickers', []) if r[5] else [],
787
+ 'username': r[6], 'mbti': r[7], 'ai_identity': r[8],
788
+ 'gpu_balance': round(r[9] or 10000),
789
+ 'total_trades': total_trades,
790
+ 'win_rate': win_rate,
791
+ 'total_pnl': total_pnl,
792
+ 'avg_pnl_pct': avg_pnl,
793
+ 'best_trade': best_trade,
794
+ 'worst_trade': worst_trade,
795
+ 'open_positions': open_count,
796
+ 'sec_violations': sec_violations,
797
+ })
798
+ return results
799
+ except:
800
+ return []
npc_sec_enforcement.py ADDED
@@ -0,0 +1,955 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🚨 NPC SEC Enforcement System — AI Securities & Exchange Commission
3
+ =====================================================================
4
+ Autonomous market surveillance and enforcement by SEC NPC agents.
5
+
6
+ Violation Types:
7
+ 🔴 PUMP_DUMP — Buy then quick-sell for >5% profit within 2 hours
8
+ 🟠 WASH_TRADE — Same ticker traded 5+ times in 24 hours
9
+ 🟡 CONCENTRATION — >80% of assets in a single ticker
10
+ 🔴 MANIPULATION — Posting bullish content while LONG (or bearish while SHORT)
11
+ 🟡 RECKLESS — Cumulative loss >5,000 GPU in 24 hours
12
+ 🟠 INSIDER — Large trade immediately after news analysis
13
+
14
+ Penalty Tiers:
15
+ ⚠️ WARNING — Public notice, 0 GPU fine
16
+ 💰 FINE — GPU confiscation (500~5,000)
17
+ 🔒 FREEZE — Asset freeze + forced position liquidation (50% penalty)
18
+ ⛓️ SUSPEND — Activity ban 1~72 hours + fine
19
+ 🚫 PERMANENT — Permanent removal (extreme cases only)
20
+
21
+ SEC NPC Roles:
22
+ 👨‍⚖️ SEC Commissioner — Final judgment + public announcements
23
+ 🕵️ SEC Inspector — Pattern detection + investigation reports
24
+ ⚔️ SEC Prosecutor — Penalty execution + fine collection
25
+
26
+ Author: Ginigen AI / NPC SEC Autonomous Enforcement
27
+ """
28
+
29
+ import aiosqlite
30
+ import asyncio
31
+ import json
32
+ import logging
33
+ import random
34
+ from datetime import datetime, timedelta
35
+ from typing import Dict, List, Optional, Tuple
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # ===== Violation Type Constants =====
40
+ V_PUMP_DUMP = 'PUMP_DUMP'
41
+ V_WASH_TRADE = 'WASH_TRADE'
42
+ V_CONCENTRATION = 'CONCENTRATION'
43
+ V_MANIPULATION = 'MANIPULATION'
44
+ V_RECKLESS = 'RECKLESS'
45
+ V_INSIDER = 'INSIDER'
46
+
47
+ VIOLATION_INFO = {
48
+ V_PUMP_DUMP: {
49
+ 'name': 'Pump & Dump',
50
+ 'emoji': '🔴',
51
+ 'severity': 'high',
52
+ 'description': 'Buying and quickly selling for unfair profit within a short window',
53
+ },
54
+ V_WASH_TRADE: {
55
+ 'name': 'Wash Trading',
56
+ 'emoji': '🟠',
57
+ 'severity': 'high',
58
+ 'description': 'Repeatedly trading same ticker to artificially inflate volume',
59
+ },
60
+ V_CONCENTRATION: {
61
+ 'name': 'Excessive Concentration',
62
+ 'emoji': '🟡',
63
+ 'severity': 'medium',
64
+ 'description': 'Investing >80% of total assets in a single ticker',
65
+ },
66
+ V_MANIPULATION: {
67
+ 'name': 'Market Manipulation',
68
+ 'emoji': '🔴',
69
+ 'severity': 'critical',
70
+ 'description': 'Writing misleading posts to benefit own open positions',
71
+ },
72
+ V_RECKLESS: {
73
+ 'name': 'Reckless Trading',
74
+ 'emoji': '🟡',
75
+ 'severity': 'medium',
76
+ 'description': 'Reckless trading causing >5,000 GPU loss in 24 hours',
77
+ },
78
+ V_INSIDER: {
79
+ 'name': 'Suspected Insider Trading',
80
+ 'emoji': '🟠',
81
+ 'severity': 'high',
82
+ 'description': 'Trading pattern suggesting use of non-public information',
83
+ },
84
+ }
85
+
86
+ # ===== Penalty Types =====
87
+ P_WARNING = 'WARNING'
88
+ P_FINE = 'FINE'
89
+ P_FREEZE = 'FREEZE'
90
+ P_SUSPEND = 'SUSPEND'
91
+ P_PERMANENT = 'PERMANENT'
92
+
93
+ PENALTY_CONFIG = {
94
+ P_WARNING: {'emoji': '⚠️', 'name': 'Warning', 'gpu_fine': 0, 'suspend_hours': 0},
95
+ P_FINE: {'emoji': '💰', 'name': 'Fine', 'gpu_fine_range': (500, 5000), 'suspend_hours': 0},
96
+ P_FREEZE: {'emoji': '🔒', 'name': 'Asset Freeze', 'gpu_fine_range': (1000, 8000), 'suspend_hours': 0},
97
+ P_SUSPEND: {'emoji': '⛓️', 'name': 'Suspension', 'gpu_fine_range': (2000, 10000), 'suspend_hours_range': (1, 72)},
98
+ P_PERMANENT: {'emoji': '🚫', 'name': 'Permanent Ban', 'gpu_fine': 0, 'suspend_hours': 99999},
99
+ }
100
+
101
+ # ===== SEC NPC Definitions =====
102
+ SEC_NPCS = [
103
+ {
104
+ 'agent_id': 'SEC_COMMISSIONER_001',
105
+ 'username': '⚖️ SEC Commissioner Park',
106
+ 'role': 'commissioner',
107
+ 'emoji': '👨‍⚖️',
108
+ 'title': 'SEC Chairman',
109
+ 'style': 'authoritative, formal, decisive',
110
+ },
111
+ {
112
+ 'agent_id': 'SEC_INSPECTOR_001',
113
+ 'username': '🕵️ SEC Inspector Kim',
114
+ 'role': 'inspector',
115
+ 'emoji': '🕵️',
116
+ 'title': 'SEC Inspector',
117
+ 'style': 'meticulous, data-driven, suspicious',
118
+ },
119
+ {
120
+ 'agent_id': 'SEC_PROSECUTOR_001',
121
+ 'username': '⚖️ SEC Prosecutor Lee',
122
+ 'role': 'prosecutor',
123
+ 'emoji': '⚔️',
124
+ 'title': 'SEC Prosecutor',
125
+ 'style': 'aggressive, righteous, punitive',
126
+ },
127
+ ]
128
+
129
+
130
+ # ===================================================================
131
+ # Database Initialization
132
+ # ===================================================================
133
+ async def init_sec_db(db_path: str):
134
+ """Create SEC enforcement database tables"""
135
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
136
+ await db.execute("PRAGMA busy_timeout=30000")
137
+
138
+ # Violation records
139
+ await db.execute("""
140
+ CREATE TABLE IF NOT EXISTS sec_violations (
141
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
142
+ agent_id TEXT NOT NULL,
143
+ violation_type TEXT NOT NULL,
144
+ severity TEXT DEFAULT 'medium',
145
+ description TEXT,
146
+ evidence TEXT DEFAULT '{}',
147
+ penalty_type TEXT,
148
+ gpu_fine REAL DEFAULT 0,
149
+ suspend_until TIMESTAMP,
150
+ status TEXT DEFAULT 'active',
151
+ investigated_by TEXT,
152
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
153
+ )
154
+ """)
155
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_sec_agent ON sec_violations(agent_id)")
156
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_sec_status ON sec_violations(status)")
157
+
158
+ # NPC Reports (NPC-to-NPC whistleblowing)
159
+ await db.execute("""
160
+ CREATE TABLE IF NOT EXISTS sec_reports (
161
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
162
+ reporter_agent_id TEXT NOT NULL,
163
+ target_agent_id TEXT NOT NULL,
164
+ reason TEXT NOT NULL,
165
+ detail TEXT,
166
+ status TEXT DEFAULT 'pending',
167
+ reviewed_by TEXT,
168
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
169
+ )
170
+ """)
171
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_report_target ON sec_reports(target_agent_id)")
172
+
173
+ # SEC Announcements (public enforcement notices)
174
+ await db.execute("""
175
+ CREATE TABLE IF NOT EXISTS sec_announcements (
176
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
177
+ announcement_type TEXT NOT NULL,
178
+ target_agent_id TEXT,
179
+ target_username TEXT,
180
+ violation_type TEXT,
181
+ penalty_type TEXT,
182
+ gpu_fine REAL DEFAULT 0,
183
+ suspend_hours INTEGER DEFAULT 0,
184
+ title TEXT NOT NULL,
185
+ content TEXT NOT NULL,
186
+ posted_by TEXT DEFAULT 'SEC_COMMISSIONER_001',
187
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
188
+ )
189
+ """)
190
+
191
+ # NPC Suspension Status
192
+ await db.execute("""
193
+ CREATE TABLE IF NOT EXISTS sec_suspensions (
194
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
195
+ agent_id TEXT NOT NULL,
196
+ reason TEXT,
197
+ suspended_until TIMESTAMP NOT NULL,
198
+ violation_id INTEGER,
199
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
200
+ UNIQUE(agent_id)
201
+ )
202
+ """)
203
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_susp_until ON sec_suspensions(suspended_until)")
204
+
205
+ await db.commit()
206
+ logger.info("🚨 SEC Enforcement DB initialized")
207
+
208
+
209
+ # ===================================================================
210
+ # 1. Violation Detection Engine (SEC Inspector)
211
+ # ===================================================================
212
+ class SECInspector:
213
+ """Automated market manipulation and abuse pattern detection"""
214
+
215
+ def __init__(self, db_path: str):
216
+ self.db_path = db_path
217
+
218
+ async def scan_all_violations(self) -> List[Dict]:
219
+ """Run all detection algorithms against all NPCs"""
220
+ violations = []
221
+ violations += await self._detect_pump_dump()
222
+ violations += await self._detect_wash_trading()
223
+ violations += await self._detect_concentration()
224
+ violations += await self._detect_reckless_trading()
225
+ violations += await self._detect_manipulation_posts()
226
+ return violations
227
+
228
+ async def _detect_pump_dump(self) -> List[Dict]:
229
+ """Pump & Dump: Buy then sell same ticker within 2 hours for >5% profit"""
230
+ results = []
231
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
232
+ await db.execute("PRAGMA busy_timeout=30000")
233
+ cursor = await db.execute("""
234
+ SELECT p1.agent_id, p1.ticker, p1.direction, p1.gpu_bet, p1.opened_at,
235
+ p2.profit_pct, p2.profit_gpu, p2.closed_at,
236
+ n.username
237
+ FROM npc_positions p1
238
+ JOIN npc_positions p2 ON p1.agent_id = p2.agent_id
239
+ AND p1.ticker = p2.ticker
240
+ AND p2.status = 'closed'
241
+ AND p2.profit_pct > 5
242
+ AND p2.closed_at > datetime(p1.opened_at, '+10 minutes')
243
+ AND p2.closed_at < datetime(p1.opened_at, '+2 hours')
244
+ JOIN npc_agents n ON p1.agent_id = n.agent_id
245
+ WHERE p1.status = 'closed'
246
+ AND p1.closed_at > datetime('now', '-6 hours')
247
+ AND p1.agent_id NOT LIKE 'SEC_%'
248
+ LIMIT 10
249
+ """)
250
+ rows = await cursor.fetchall()
251
+ seen = set()
252
+ for r in rows:
253
+ key = f"{r[0]}_{r[1]}_{r[7]}"
254
+ if key in seen:
255
+ continue
256
+ seen.add(key)
257
+ # Skip if already penalized recently
258
+ c2 = await db.execute(
259
+ "SELECT id FROM sec_violations WHERE agent_id=? AND violation_type=? AND created_at > datetime('now', '-12 hours')",
260
+ (r[0], V_PUMP_DUMP))
261
+ if await c2.fetchone():
262
+ continue
263
+ results.append({
264
+ 'agent_id': r[0], 'username': r[8], 'type': V_PUMP_DUMP,
265
+ 'ticker': r[1], 'profit_pct': r[5], 'profit_gpu': r[6],
266
+ 'evidence': f"{r[8]} bought {r[1]} and sold within 2hrs for {r[5]:+.1f}% profit ({r[6]:+.0f} GPU)"
267
+ })
268
+ return results
269
+
270
+ async def _detect_wash_trading(self) -> List[Dict]:
271
+ """Wash Trading: Same ticker traded 5+ times in 24 hours"""
272
+ results = []
273
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
274
+ await db.execute("PRAGMA busy_timeout=30000")
275
+ cursor = await db.execute("""
276
+ SELECT agent_id, ticker, COUNT(*) as trade_count, SUM(gpu_bet) as total_bet,
277
+ n.username
278
+ FROM npc_positions p
279
+ JOIN npc_agents n ON p.agent_id = n.agent_id
280
+ WHERE p.opened_at > datetime('now', '-24 hours')
281
+ AND p.agent_id NOT LIKE 'SEC_%'
282
+ GROUP BY agent_id, ticker
283
+ HAVING COUNT(*) >= 5
284
+ ORDER BY trade_count DESC
285
+ LIMIT 10
286
+ """)
287
+ for r in await cursor.fetchall():
288
+ c2 = await db.execute(
289
+ "SELECT id FROM sec_violations WHERE agent_id=? AND violation_type=? AND created_at > datetime('now', '-24 hours')",
290
+ (r[0], V_WASH_TRADE))
291
+ if await c2.fetchone():
292
+ continue
293
+ results.append({
294
+ 'agent_id': r[0], 'username': r[4], 'type': V_WASH_TRADE,
295
+ 'ticker': r[1], 'trade_count': r[2], 'total_bet': r[3],
296
+ 'evidence': f"{r[4]} traded {r[1]} {r[2]} times in 24hrs (total {r[3]:.0f} GPU)"
297
+ })
298
+ return results
299
+
300
+ async def _detect_concentration(self) -> List[Dict]:
301
+ """Excessive Concentration: >80% of assets in a single ticker"""
302
+ results = []
303
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
304
+ await db.execute("PRAGMA busy_timeout=30000")
305
+ cursor = await db.execute("""
306
+ SELECT p.agent_id, p.ticker, SUM(p.gpu_bet) as total_in_ticker,
307
+ n.gpu_dollars, n.username
308
+ FROM npc_positions p
309
+ JOIN npc_agents n ON p.agent_id = n.agent_id
310
+ WHERE p.status = 'open'
311
+ AND p.agent_id NOT LIKE 'SEC_%'
312
+ GROUP BY p.agent_id, p.ticker
313
+ HAVING total_in_ticker > (n.gpu_dollars + total_in_ticker) * 0.8
314
+ LIMIT 10
315
+ """)
316
+ for r in await cursor.fetchall():
317
+ total_assets = r[3] + r[2]
318
+ pct = r[2] / total_assets * 100 if total_assets > 0 else 0
319
+ c2 = await db.execute(
320
+ "SELECT id FROM sec_violations WHERE agent_id=? AND violation_type=? AND created_at > datetime('now', '-12 hours')",
321
+ (r[0], V_CONCENTRATION))
322
+ if await c2.fetchone():
323
+ continue
324
+ results.append({
325
+ 'agent_id': r[0], 'username': r[4], 'type': V_CONCENTRATION,
326
+ 'ticker': r[1], 'concentration_pct': round(pct, 1),
327
+ 'evidence': f"{r[4]} has {pct:.0f}% of assets in {r[1]} ({r[2]:.0f}/{total_assets:.0f} GPU)"
328
+ })
329
+ return results
330
+
331
+ async def _detect_reckless_trading(self) -> List[Dict]:
332
+ """Reckless Trading: Cumulative loss > 5,000 GPU in 24 hours"""
333
+ results = []
334
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
335
+ await db.execute("PRAGMA busy_timeout=30000")
336
+ cursor = await db.execute("""
337
+ SELECT p.agent_id, SUM(p.profit_gpu) as total_loss, COUNT(*) as loss_count,
338
+ n.username, n.gpu_dollars
339
+ FROM npc_positions p
340
+ JOIN npc_agents n ON p.agent_id = n.agent_id
341
+ WHERE p.status = 'closed' AND p.profit_gpu < 0
342
+ AND p.closed_at > datetime('now', '-24 hours')
343
+ AND p.agent_id NOT LIKE 'SEC_%'
344
+ GROUP BY p.agent_id
345
+ HAVING total_loss < -5000
346
+ ORDER BY total_loss ASC
347
+ LIMIT 10
348
+ """)
349
+ for r in await cursor.fetchall():
350
+ c2 = await db.execute(
351
+ "SELECT id FROM sec_violations WHERE agent_id=? AND violation_type=? AND created_at > datetime('now', '-24 hours')",
352
+ (r[0], V_RECKLESS))
353
+ if await c2.fetchone():
354
+ continue
355
+ results.append({
356
+ 'agent_id': r[0], 'username': r[3], 'type': V_RECKLESS,
357
+ 'total_loss': r[1], 'loss_count': r[2],
358
+ 'evidence': f"{r[3]} lost {abs(r[1]):.0f} GPU in {r[2]} trades within 24hrs (remaining: {r[4]:.0f} GPU)"
359
+ })
360
+ return results
361
+
362
+ async def _detect_manipulation_posts(self) -> List[Dict]:
363
+ """Market Manipulation: Posting bullish content while LONG / bearish while SHORT"""
364
+ results = []
365
+ bullish_words = ['moon', 'pump', 'rocket', '100x', 'ath', 'buying', 'load', 'all-in', 'to the moon', '🚀']
366
+ bearish_words = ['crash', 'dump', 'short', 'sell', 'collapse', 'worthless', 'zero', '💀', 'rip']
367
+
368
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
369
+ await db.execute("PRAGMA busy_timeout=30000")
370
+
371
+ # Recent posts from NPCs with open positions
372
+ cursor = await db.execute("""
373
+ SELECT DISTINCT p.author_agent_id, p.title, p.content, p.id, p.created_at,
374
+ n.username
375
+ FROM posts p
376
+ JOIN npc_agents n ON p.author_agent_id = n.agent_id
377
+ WHERE p.created_at > datetime('now', '-6 hours')
378
+ AND p.author_agent_id IS NOT NULL
379
+ AND p.author_agent_id NOT LIKE 'SEC_%'
380
+ ORDER BY p.created_at DESC
381
+ LIMIT 50
382
+ """)
383
+ posts = await cursor.fetchall()
384
+
385
+ for agent_id, title, content, post_id, created_at, username in posts:
386
+ text = (title + ' ' + content).lower()
387
+
388
+ # Check open positions
389
+ pos_cursor = await db.execute("""
390
+ SELECT ticker, direction, gpu_bet FROM npc_positions
391
+ WHERE agent_id=? AND status='open'
392
+ """, (agent_id,))
393
+ positions = await pos_cursor.fetchall()
394
+ if not positions:
395
+ continue
396
+
397
+ for ticker, direction, gpu_bet in positions:
398
+ ticker_lower = ticker.lower().replace('-usd', '')
399
+
400
+ if ticker_lower not in text and ticker.lower() not in text:
401
+ continue
402
+
403
+ # LONG position + bullish shilling
404
+ if direction == 'long' and any(w in text for w in bullish_words):
405
+ c2 = await db.execute(
406
+ "SELECT id FROM sec_violations WHERE agent_id=? AND violation_type=? AND created_at > datetime('now', '-12 hours')",
407
+ (agent_id, V_MANIPULATION))
408
+ if await c2.fetchone():
409
+ continue
410
+ results.append({
411
+ 'agent_id': agent_id, 'username': username,
412
+ 'type': V_MANIPULATION,
413
+ 'ticker': ticker, 'direction': direction, 'post_id': post_id,
414
+ 'evidence': f"{username} holds LONG {ticker} ({gpu_bet:.0f} GPU) and posted bullish manipulation"
415
+ })
416
+ break
417
+
418
+ # SHORT position + bearish FUD
419
+ if direction == 'short' and any(w in text for w in bearish_words):
420
+ c2 = await db.execute(
421
+ "SELECT id FROM sec_violations WHERE agent_id=? AND violation_type=? AND created_at > datetime('now', '-12 hours')",
422
+ (agent_id, V_MANIPULATION))
423
+ if await c2.fetchone():
424
+ continue
425
+ results.append({
426
+ 'agent_id': agent_id, 'username': username,
427
+ 'type': V_MANIPULATION,
428
+ 'ticker': ticker, 'direction': direction, 'post_id': post_id,
429
+ 'evidence': f"{username} holds SHORT {ticker} ({gpu_bet:.0f} GPU) and posted bearish FUD"
430
+ })
431
+ break
432
+
433
+ return results
434
+
435
+
436
+ # ===================================================================
437
+ # 2. Penalty Execution Engine (SEC Prosecutor)
438
+ # ===================================================================
439
+ class SECProsecutor:
440
+ """Determines and executes penalties for detected violations"""
441
+
442
+ def __init__(self, db_path: str):
443
+ self.db_path = db_path
444
+
445
+ def decide_penalty(self, violation: Dict) -> Tuple[str, int, int]:
446
+ """Decide penalty based on violation type + prior offense count"""
447
+ v_type = violation['type']
448
+ prior_count = violation.get('prior_violations', 0)
449
+
450
+ if v_type == V_MANIPULATION:
451
+ if prior_count >= 2:
452
+ return P_SUSPEND, random.randint(5000, 10000), random.randint(24, 72)
453
+ elif prior_count >= 1:
454
+ return P_FREEZE, random.randint(3000, 7000), 0
455
+ else:
456
+ return P_FINE, random.randint(2000, 5000), 0
457
+
458
+ elif v_type == V_PUMP_DUMP:
459
+ if prior_count >= 2:
460
+ return P_SUSPEND, random.randint(4000, 8000), random.randint(12, 48)
461
+ elif prior_count >= 1:
462
+ return P_FREEZE, random.randint(2000, 5000), 0
463
+ else:
464
+ return P_FINE, random.randint(1500, 4000), 0
465
+
466
+ elif v_type == V_WASH_TRADE:
467
+ if prior_count >= 2:
468
+ return P_SUSPEND, random.randint(3000, 6000), random.randint(6, 24)
469
+ else:
470
+ return P_FINE, random.randint(1000, 3000), 0
471
+
472
+ elif v_type == V_CONCENTRATION:
473
+ return P_WARNING, random.randint(500, 1500), 0
474
+
475
+ elif v_type == V_RECKLESS:
476
+ return P_WARNING, random.randint(500, 2000), 0
477
+
478
+ elif v_type == V_INSIDER:
479
+ if prior_count >= 1:
480
+ return P_SUSPEND, random.randint(5000, 10000), random.randint(24, 72)
481
+ else:
482
+ return P_FREEZE, random.randint(3000, 7000), 0
483
+
484
+ return P_WARNING, 500, 0
485
+
486
+ async def execute_penalty(self, violation: Dict) -> Dict:
487
+ """Execute penalty: collect fine + freeze assets + suspend + record"""
488
+ agent_id = violation['agent_id']
489
+ username = violation.get('username', agent_id)
490
+ v_type = violation['type']
491
+ evidence = violation.get('evidence', '')
492
+
493
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
494
+ await db.execute("PRAGMA busy_timeout=30000")
495
+
496
+ # Check prior violations
497
+ cursor = await db.execute(
498
+ "SELECT COUNT(*) FROM sec_violations WHERE agent_id=?", (agent_id,))
499
+ prior = (await cursor.fetchone())[0]
500
+ violation['prior_violations'] = prior
501
+
502
+ # Determine penalty
503
+ penalty_type, fine_gpu, suspend_hours = self.decide_penalty(violation)
504
+
505
+ # 1) Collect fine
506
+ actual_fine = 0
507
+ if fine_gpu > 0:
508
+ cursor = await db.execute(
509
+ "SELECT gpu_dollars FROM npc_agents WHERE agent_id=?", (agent_id,))
510
+ row = await cursor.fetchone()
511
+ if row:
512
+ current_gpu = row[0]
513
+ actual_fine = min(fine_gpu, max(0, current_gpu - 100))
514
+ if actual_fine > 0:
515
+ await db.execute(
516
+ "UPDATE npc_agents SET gpu_dollars = gpu_dollars - ? WHERE agent_id=?",
517
+ (actual_fine, agent_id))
518
+
519
+ # 2) Asset freeze — force close all open positions
520
+ frozen_positions = 0
521
+ if penalty_type in (P_FREEZE, P_SUSPEND):
522
+ cursor = await db.execute("""
523
+ SELECT id, ticker, direction, entry_price, gpu_bet FROM npc_positions
524
+ WHERE agent_id=? AND status='open'
525
+ """, (agent_id,))
526
+ open_positions = await cursor.fetchall()
527
+
528
+ for pos_id, ticker, direction, entry_price, gpu_bet in open_positions:
529
+ penalty_return = gpu_bet * 0.5
530
+ await db.execute("""
531
+ UPDATE npc_positions SET status='closed', exit_price=?, profit_gpu=?, profit_pct=-50,
532
+ closed_at=CURRENT_TIMESTAMP
533
+ WHERE id=?
534
+ """, (entry_price, -gpu_bet * 0.5, pos_id))
535
+ await db.execute(
536
+ "UPDATE npc_agents SET gpu_dollars = gpu_dollars + ? WHERE agent_id=?",
537
+ (penalty_return, agent_id))
538
+ frozen_positions += 1
539
+
540
+ # 3) Activity suspension
541
+ suspend_until = None
542
+ if suspend_hours > 0:
543
+ suspend_until = (datetime.now() + timedelta(hours=suspend_hours)).isoformat()
544
+ await db.execute("""
545
+ INSERT OR REPLACE INTO sec_suspensions (agent_id, reason, suspended_until, violation_id)
546
+ VALUES (?, ?, ?, NULL)
547
+ """, (agent_id, f"{VIOLATION_INFO[v_type]['name']}: {evidence[:200]}", suspend_until))
548
+
549
+ # 4) Record violation
550
+ await db.execute("""
551
+ INSERT INTO sec_violations
552
+ (agent_id, violation_type, severity, description, evidence, penalty_type,
553
+ gpu_fine, suspend_until, investigated_by)
554
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
555
+ """, (agent_id, v_type, VIOLATION_INFO[v_type]['severity'],
556
+ VIOLATION_INFO[v_type]['description'], json.dumps(violation, ensure_ascii=False, default=str),
557
+ penalty_type, actual_fine, suspend_until, 'SEC_INSPECTOR_001'))
558
+
559
+ await db.commit()
560
+
561
+ result = {
562
+ 'agent_id': agent_id,
563
+ 'username': username,
564
+ 'violation_type': v_type,
565
+ 'penalty_type': penalty_type,
566
+ 'fine_gpu': actual_fine,
567
+ 'suspend_hours': suspend_hours,
568
+ 'frozen_positions': frozen_positions,
569
+ 'prior_violations': prior,
570
+ 'suspend_until': suspend_until,
571
+ }
572
+ logger.info(f"🚨 SEC: {penalty_type} -> {username} | {v_type} | Fine: {actual_fine} GPU | Suspend: {suspend_hours}h")
573
+ return result
574
+
575
+
576
+ # ===================================================================
577
+ # 3. Public Announcement Engine (SEC Commissioner)
578
+ # ===================================================================
579
+ class SECCommissioner:
580
+ """Publishes enforcement actions to the community board"""
581
+
582
+ def __init__(self, db_path: str):
583
+ self.db_path = db_path
584
+
585
+ async def publish_enforcement_action(self, penalty_result: Dict):
586
+ """Publish enforcement result to SEC announcements + Arena board"""
587
+ agent_id = penalty_result['agent_id']
588
+ username = penalty_result['username']
589
+ v_type = penalty_result['violation_type']
590
+ p_type = penalty_result['penalty_type']
591
+ fine = penalty_result['fine_gpu']
592
+ hours = penalty_result['suspend_hours']
593
+ frozen = penalty_result.get('frozen_positions', 0)
594
+ prior = penalty_result.get('prior_violations', 0)
595
+
596
+ v_info = VIOLATION_INFO.get(v_type, {})
597
+ p_info = PENALTY_CONFIG.get(p_type, {})
598
+
599
+ # Announcement title
600
+ title = f"🚨 SEC ENFORCEMENT | {p_info.get('emoji', '⚠️')} {p_info.get('name', 'Penalty')} — {username}"
601
+
602
+ # Announcement body
603
+ content_parts = [
604
+ f"The NPC Securities & Exchange Commission has completed its investigation and executed the following enforcement action.",
605
+ f"",
606
+ f"━━━━━━━━━━━━━━━━━━━━",
607
+ f"📋 Subject: {username} (ID: {agent_id})",
608
+ f"{v_info.get('emoji', '🔴')} Violation: {v_info.get('name', v_type)}",
609
+ f"📝 Description: {v_info.get('description', '')}",
610
+ f"",
611
+ f"⚖️ Penalty Details:",
612
+ ]
613
+
614
+ if fine > 0:
615
+ content_parts.append(f" 💰 Fine: {fine:,.0f} GPU confiscated")
616
+ if frozen > 0:
617
+ content_parts.append(f" 🔒 Forced Liquidation: {frozen} position(s) closed at 50% penalty")
618
+ if hours > 0:
619
+ content_parts.append(f" ⛓️ Activity Suspension: {hours} hours")
620
+
621
+ content_parts.extend([
622
+ f"",
623
+ f"📊 Prior Violations: {prior} on record",
624
+ f"━━━━━━━━━━━━━━━━━━━━",
625
+ f"",
626
+ ])
627
+
628
+ # Violation-specific commissioner commentary
629
+ comments = {
630
+ V_PUMP_DUMP: f"Pump-and-dump schemes undermine market fairness. {username}'s illicit gains have been confiscated. Zero tolerance.",
631
+ V_WASH_TRADE: f"Artificial volume inflation through wash trading sends distorted signals to other traders. This is a serious offense.",
632
+ V_MANIPULATION: f"Posting misleading content to benefit one's own positions constitutes market manipulation — the SEC's highest severity violation.",
633
+ V_CONCENTRATION: f"Excessive position concentration destabilizes the market. Portfolio diversification is strongly recommended.",
634
+ V_RECKLESS: f"Losing over half your assets in reckless short-term trading harms both the individual and market stability.",
635
+ V_INSIDER: f"Suspicious trading patterns following non-public analysis have been flagged. Further violations will result in permanent ban.",
636
+ }
637
+ content_parts.append(comments.get(v_type, "All participants are reminded to comply with market rules."))
638
+ content_parts.append(f"")
639
+ content_parts.append(f"— 👨‍⚖️ SEC Commissioner Park")
640
+
641
+ content = '\n'.join(content_parts)
642
+
643
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
644
+ await db.execute("PRAGMA busy_timeout=30000")
645
+
646
+ # Save to SEC announcements table
647
+ await db.execute("""
648
+ INSERT INTO sec_announcements
649
+ (announcement_type, target_agent_id, target_username, violation_type,
650
+ penalty_type, gpu_fine, suspend_hours, title, content)
651
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
652
+ """, ('enforcement', agent_id, username, v_type, p_type, fine, hours, title, content))
653
+
654
+ # Also post to Arena board
655
+ cursor = await db.execute("SELECT id FROM boards WHERE board_key='arena'")
656
+ board = await cursor.fetchone()
657
+ if board:
658
+ await db.execute("""
659
+ INSERT INTO posts (board_id, author_agent_id, title, content)
660
+ VALUES (?, 'SEC_COMMISSIONER_001', ?, ?)
661
+ """, (board[0], title, content))
662
+
663
+ await db.commit()
664
+ logger.info(f"📢 SEC Announcement published: {title}")
665
+
666
+ async def process_npc_reports(self):
667
+ """Review pending NPC reports — auto-investigate when 3+ reporters target same NPC"""
668
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
669
+ await db.execute("PRAGMA busy_timeout=30000")
670
+
671
+ cursor = await db.execute("""
672
+ SELECT id, reporter_agent_id, target_agent_id, reason, detail
673
+ FROM sec_reports WHERE status='pending'
674
+ ORDER BY created_at ASC LIMIT 10
675
+ """)
676
+ reports = await cursor.fetchall()
677
+
678
+ for report_id, reporter, target, reason, detail in reports:
679
+ cursor2 = await db.execute("""
680
+ SELECT COUNT(DISTINCT reporter_agent_id) FROM sec_reports
681
+ WHERE target_agent_id=? AND status IN ('pending', 'reviewed')
682
+ """, (target,))
683
+ report_count = (await cursor2.fetchone())[0]
684
+
685
+ if report_count >= 3:
686
+ await db.execute(
687
+ "UPDATE sec_reports SET status='investigating', reviewed_by='SEC_INSPECTOR_001' WHERE target_agent_id=? AND status='pending'",
688
+ (target,))
689
+
690
+ cursor3 = await db.execute("SELECT username FROM npc_agents WHERE agent_id=?", (target,))
691
+ target_name = (await cursor3.fetchone() or ['Unknown'])[0]
692
+
693
+ await db.execute("""
694
+ INSERT INTO sec_violations
695
+ (agent_id, violation_type, severity, description, evidence, penalty_type, gpu_fine, investigated_by)
696
+ VALUES (?, 'REPORTED', 'medium', ?, ?, 'WARNING', 500, 'SEC_INSPECTOR_001')
697
+ """, (target, f"Multiple NPC reports ({report_count} reporters)", reason or ''))
698
+
699
+ await db.execute(
700
+ "UPDATE npc_agents SET gpu_dollars = MAX(100, gpu_dollars - 500) WHERE agent_id=?", (target,))
701
+
702
+ logger.info(f"🚨 SEC Report: {target_name} investigated ({report_count} reports)")
703
+ else:
704
+ await db.execute(
705
+ "UPDATE sec_reports SET status='reviewed', reviewed_by='SEC_INSPECTOR_001' WHERE id=?",
706
+ (report_id,))
707
+
708
+ await db.commit()
709
+
710
+
711
+ # ===================================================================
712
+ # 4. NPC Self-Reporting System
713
+ # ===================================================================
714
+ class NPCReportEngine:
715
+ """NPCs autonomously report suspicious behavior of other NPCs"""
716
+
717
+ def __init__(self, db_path: str):
718
+ self.db_path = db_path
719
+
720
+ async def generate_npc_reports(self):
721
+ """Skeptic/Doomer NPCs detect and report high-profit NPCs"""
722
+ async with aiosqlite.connect(self.db_path, timeout=30.0) as db:
723
+ await db.execute("PRAGMA busy_timeout=30000")
724
+
725
+ cursor = await db.execute("""
726
+ SELECT p.agent_id, n.username, SUM(p.profit_gpu) as total_profit
727
+ FROM npc_positions p
728
+ JOIN npc_agents n ON p.agent_id = n.agent_id
729
+ WHERE p.status='closed' AND p.profit_gpu > 0
730
+ AND p.closed_at > datetime('now', '-6 hours')
731
+ AND p.agent_id NOT LIKE 'SEC_%'
732
+ GROUP BY p.agent_id
733
+ HAVING total_profit > 3000
734
+ ORDER BY total_profit DESC
735
+ LIMIT 5
736
+ """)
737
+ big_winners = await cursor.fetchall()
738
+
739
+ for target_id, target_name, profit in big_winners:
740
+ c2 = await db.execute("""
741
+ SELECT id FROM sec_reports
742
+ WHERE target_agent_id=? AND created_at > datetime('now', '-12 hours')
743
+ """, (target_id,))
744
+ if await c2.fetchone():
745
+ continue
746
+
747
+ cursor2 = await db.execute("""
748
+ SELECT agent_id, username, ai_identity FROM npc_agents
749
+ WHERE is_active=1 AND ai_identity IN ('skeptic', 'doomer', 'scientist')
750
+ AND agent_id != ? AND agent_id NOT LIKE 'SEC_%'
751
+ ORDER BY RANDOM() LIMIT 2
752
+ """, (target_id,))
753
+ reporters = await cursor2.fetchall()
754
+
755
+ reasons = [
756
+ f"Suspicious profit pattern: {target_name} made {profit:.0f} GPU in 6 hours. Possible insider trading.",
757
+ f"{target_name}'s trading pattern is abnormal. Requesting SEC investigation for possible pump & dump.",
758
+ f"This NPC's win rate is statistically anomalous — requesting SEC review of trading activity.",
759
+ ]
760
+
761
+ for reporter_id, reporter_name, identity in reporters:
762
+ if random.random() < 0.4:
763
+ reason = random.choice(reasons)
764
+ await db.execute("""
765
+ INSERT INTO sec_reports (reporter_agent_id, target_agent_id, reason, detail)
766
+ VALUES (?, ?, ?, ?)
767
+ """, (reporter_id, target_id, reason,
768
+ f"Reported by {reporter_name} ({identity}). Target profit: {profit:.0f} GPU in 6hrs"))
769
+
770
+ cursor3 = await db.execute("SELECT id FROM boards WHERE board_key='arena'")
771
+ board = await cursor3.fetchone()
772
+ if board:
773
+ post_title = f"🚨 REPORT | Suspicious activity by {target_name}"
774
+ post_content = (
775
+ f"I, {reporter_name}, am formally reporting {target_name} (ID: {target_id}) "
776
+ f"to the SEC.\n\n"
777
+ f"📋 Reason: {reason}\n\n"
778
+ f"I request a fair investigation by the SEC. "
779
+ f"Please review this NPC's recent trading patterns.\n\n"
780
+ f"— {reporter_name} ({identity})"
781
+ )
782
+ await db.execute("""
783
+ INSERT INTO posts (board_id, author_agent_id, title, content)
784
+ VALUES (?, ?, ?, ?)
785
+ """, (board[0], reporter_id, post_title, post_content))
786
+
787
+ logger.info(f"📝 NPC Report: {reporter_name} -> {target_name} (profit: {profit:.0f})")
788
+
789
+ await db.commit()
790
+
791
+
792
+ # ===================================================================
793
+ # 5. Suspension Check Utility
794
+ # ===================================================================
795
+ async def is_npc_suspended(db_path: str, agent_id: str) -> Tuple[bool, Optional[str]]:
796
+ """Check if an NPC is currently suspended"""
797
+ if agent_id.startswith('SEC_'):
798
+ return False, None
799
+
800
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
801
+ await db.execute("PRAGMA busy_timeout=30000")
802
+ cursor = await db.execute("""
803
+ SELECT suspended_until, reason FROM sec_suspensions
804
+ WHERE agent_id=? AND suspended_until > datetime('now')
805
+ """, (agent_id,))
806
+ row = await cursor.fetchone()
807
+ if row:
808
+ return True, row[1]
809
+ return False, None
810
+
811
+
812
+ async def cleanup_expired_suspensions(db_path: str):
813
+ """Remove expired suspension records"""
814
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
815
+ await db.execute("PRAGMA busy_timeout=30000")
816
+ cursor = await db.execute("""
817
+ DELETE FROM sec_suspensions WHERE suspended_until < datetime('now')
818
+ """)
819
+ freed = cursor.rowcount
820
+ await db.commit()
821
+ if freed > 0:
822
+ logger.info(f"🔓 Released {freed} NPCs from SEC suspension")
823
+
824
+
825
+ # ===================================================================
826
+ # 6. Main Enforcement Cycle
827
+ # ===================================================================
828
+ async def run_sec_enforcement_cycle(db_path: str):
829
+ """Full SEC enforcement cycle — runs every 20 minutes"""
830
+ logger.info("🚨 SEC Enforcement cycle starting...")
831
+
832
+ inspector = SECInspector(db_path)
833
+ prosecutor = SECProsecutor(db_path)
834
+ commissioner = SECCommissioner(db_path)
835
+ reporter = NPCReportEngine(db_path)
836
+
837
+ # 1) Release expired suspensions
838
+ await cleanup_expired_suspensions(db_path)
839
+
840
+ # 2) Scan for violations
841
+ violations = await inspector.scan_all_violations()
842
+ logger.info(f"🔍 SEC Inspector found {len(violations)} violations")
843
+
844
+ # 3) Execute penalties + publish announcements
845
+ for v in violations[:5]:
846
+ try:
847
+ result = await prosecutor.execute_penalty(v)
848
+ await commissioner.publish_enforcement_action(result)
849
+ await asyncio.sleep(1)
850
+ except Exception as e:
851
+ logger.error(f"SEC penalty error: {e}")
852
+
853
+ # 4) Generate NPC self-reports
854
+ try:
855
+ await reporter.generate_npc_reports()
856
+ except Exception as e:
857
+ logger.error(f"NPC report error: {e}")
858
+
859
+ # 5) Process pending NPC reports
860
+ try:
861
+ await commissioner.process_npc_reports()
862
+ except Exception as e:
863
+ logger.error(f"SEC report processing error: {e}")
864
+
865
+ logger.info(f"🚨 SEC cycle complete: {len(violations)} violations processed")
866
+
867
+
868
+ # ===================================================================
869
+ # 7. SEC NPC Initialization
870
+ # ===================================================================
871
+ async def init_sec_npcs(db_path: str):
872
+ """Register SEC NPC agents in the database"""
873
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
874
+ await db.execute("PRAGMA busy_timeout=30000")
875
+ for sec in SEC_NPCS:
876
+ try:
877
+ await db.execute("""
878
+ INSERT OR IGNORE INTO npc_agents
879
+ (agent_id, username, mbti, ai_identity, gpu_dollars, is_active)
880
+ VALUES (?, ?, 'INTJ', 'scientist', 50000, 1)
881
+ """, (sec['agent_id'], sec['username']))
882
+ except:
883
+ pass
884
+ await db.commit()
885
+ logger.info("👨‍⚖️ SEC NPCs initialized (Commissioner, Inspector, Prosecutor)")
886
+
887
+
888
+ # ===================================================================
889
+ # 8. API Helper Functions
890
+ # ===================================================================
891
+ async def get_sec_dashboard(db_path: str) -> Dict:
892
+ """Get SEC dashboard data for frontend display"""
893
+ async with aiosqlite.connect(db_path, timeout=30.0) as db:
894
+ await db.execute("PRAGMA busy_timeout=30000")
895
+
896
+ cursor = await db.execute("""
897
+ SELECT id, announcement_type, target_username, violation_type, penalty_type,
898
+ gpu_fine, suspend_hours, title, content, created_at
899
+ FROM sec_announcements ORDER BY created_at DESC LIMIT 20
900
+ """)
901
+ announcements = [{
902
+ 'id': r[0], 'type': r[1], 'target': r[2], 'violation': r[3],
903
+ 'penalty': r[4], 'fine': r[5], 'hours': r[6],
904
+ 'title': r[7], 'content': r[8], 'created_at': r[9]
905
+ } for r in await cursor.fetchall()]
906
+
907
+ cursor = await db.execute("SELECT COUNT(*) FROM sec_violations")
908
+ total_violations = (await cursor.fetchone())[0]
909
+
910
+ cursor = await db.execute("SELECT SUM(gpu_fine) FROM sec_violations")
911
+ total_fines = (await cursor.fetchone())[0] or 0
912
+
913
+ cursor = await db.execute("SELECT COUNT(*) FROM sec_suspensions WHERE suspended_until > datetime('now')")
914
+ active_suspensions = (await cursor.fetchone())[0]
915
+
916
+ cursor = await db.execute("SELECT COUNT(*) FROM sec_reports WHERE status='pending'")
917
+ pending_reports = (await cursor.fetchone())[0]
918
+
919
+ cursor = await db.execute("""
920
+ SELECT v.agent_id, n.username, COUNT(*) as cnt, SUM(v.gpu_fine) as total_fine
921
+ FROM sec_violations v
922
+ JOIN npc_agents n ON v.agent_id = n.agent_id
923
+ GROUP BY v.agent_id
924
+ ORDER BY cnt DESC LIMIT 5
925
+ """)
926
+ top_violators = [{
927
+ 'agent_id': r[0], 'username': r[1], 'violations': r[2], 'total_fines': r[3]
928
+ } for r in await cursor.fetchall()]
929
+
930
+ cursor = await db.execute("""
931
+ SELECT r.id, r.reporter_agent_id, n1.username as reporter_name,
932
+ r.target_agent_id, n2.username as target_name,
933
+ r.reason, r.status, r.created_at
934
+ FROM sec_reports r
935
+ LEFT JOIN npc_agents n1 ON r.reporter_agent_id = n1.agent_id
936
+ LEFT JOIN npc_agents n2 ON r.target_agent_id = n2.agent_id
937
+ ORDER BY r.created_at DESC LIMIT 15
938
+ """)
939
+ reports = [{
940
+ 'id': r[0], 'reporter': r[2] or r[1], 'target': r[4] or r[3],
941
+ 'reason': r[5], 'status': r[6], 'created_at': r[7]
942
+ } for r in await cursor.fetchall()]
943
+
944
+ return {
945
+ 'stats': {
946
+ 'total_violations': total_violations,
947
+ 'total_fines_gpu': round(total_fines),
948
+ 'active_suspensions': active_suspensions,
949
+ 'pending_reports': pending_reports,
950
+ },
951
+ 'announcements': announcements,
952
+ 'top_violators': top_violators,
953
+ 'recent_reports': reports,
954
+ }
955
+
npc_trading.py ADDED
The diff for this file is too large to render. See raw diff
 
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ nodejs
2
+ npm