Spaces:
Running
Running
Upload 8 files
Browse files- battle_arena.py +937 -0
- index.html +0 -0
- npc_core.py +0 -0
- npc_intelligence.py +981 -0
- npc_memory_evolution.py +800 -0
- npc_sec_enforcement.py +955 -0
- npc_trading.py +0 -0
- packages.txt +2 -0
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
|