seawolf2357 commited on
Commit
690dc14
·
verified ·
1 Parent(s): 8908ef7

fix: tz-naive datetime crash + initial-backup safety + English-only sweep

Browse files
Files changed (1) hide show
  1. npc_trading.py +186 -182
npc_trading.py CHANGED
@@ -1,9 +1,9 @@
1
  """
2
- 📈 NPC Trading Arena — AI 투자 대결 시스템
3
  ==========================================
4
- NPC들이 실제 주식/코인 가격을 기반으로 Long/Short 투자 대결
5
- yfinance 기반 안정적 가격 수집
6
- 레버리지(1x~100x) + 마진콜 청산 시스템
7
  """
8
 
9
  import aiosqlite, asyncio, random, json, logging
@@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Tuple
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
- # yfinance 임포트 (없으면 fallback)
16
  try:
17
  import yfinance as yf
18
  HAS_YFINANCE = True
@@ -22,9 +22,9 @@ except ImportError:
22
  import requests
23
  logger.warning("⚠️ yfinance not installed, using raw API fallback")
24
 
25
- # ===== 종목 정의 =====
26
  STOCK_TICKERS = [
27
- # 👑 매그니피센트 7 & AI 반도체
28
  {'ticker': 'NVDA', 'name': 'NVIDIA', 'emoji': '🟢', 'type': 'stock', 'cat': 'ai'},
29
  {'ticker': 'MSFT', 'name': 'Microsoft', 'emoji': '🪟', 'type': 'stock', 'cat': 'ai'},
30
  {'ticker': 'AAPL', 'name': 'Apple', 'emoji': '🍎', 'type': 'stock', 'cat': 'ai'},
@@ -35,13 +35,13 @@ STOCK_TICKERS = [
35
  {'ticker': 'AMD', 'name': 'AMD', 'emoji': '🔴', 'type': 'stock', 'cat': 'ai'},
36
  {'ticker': 'TSM', 'name': 'TSMC', 'emoji': '🇹🇼', 'type': 'stock', 'cat': 'ai'},
37
  {'ticker': 'AVGO', 'name': 'Broadcom', 'emoji': '📡', 'type': 'stock', 'cat': 'ai'},
38
- # 🚀 기술/플랫폼 & 대장주
39
  {'ticker': 'PLTR', 'name': 'Palantir', 'emoji': '🔮', 'type': 'stock', 'cat': 'tech'},
40
  {'ticker': 'COIN', 'name': 'Coinbase', 'emoji': '🪙', 'type': 'stock', 'cat': 'tech'},
41
  {'ticker': 'NFLX', 'name': 'Netflix', 'emoji': '🎬', 'type': 'stock', 'cat': 'tech'},
42
  {'ticker': 'UBER', 'name': 'Uber', 'emoji': '🚕', 'type': 'stock', 'cat': 'tech'},
43
  {'ticker': 'ARM', 'name': 'ARM Holdings', 'emoji': '💪', 'type': 'stock', 'cat': 'tech'},
44
- # 🏛 다우 우량주 & 거시경제
45
  {'ticker': 'JPM', 'name': 'JPMorgan', 'emoji': '🏦', 'type': 'stock', 'cat': 'dow'},
46
  {'ticker': 'GS', 'name': 'Goldman Sachs', 'emoji': '🤵', 'type': 'stock', 'cat': 'dow'},
47
  {'ticker': 'V', 'name': 'Visa', 'emoji': '💳', 'type': 'stock', 'cat': 'dow'},
@@ -55,7 +55,7 @@ STOCK_TICKERS = [
55
  ]
56
 
57
  CRYPTO_TICKERS = [
58
- # 🪙 크립토 변동성 5대장
59
  {'ticker': 'BTC-USD', 'name': 'Bitcoin', 'emoji': '₿', 'type': 'crypto', 'cat': 'crypto'},
60
  {'ticker': 'ETH-USD', 'name': 'Ethereum', 'emoji': 'Ξ', 'type': 'crypto', 'cat': 'crypto'},
61
  {'ticker': 'SOL-USD', 'name': 'Solana', 'emoji': '◎', 'type': 'crypto', 'cat': 'crypto'},
@@ -65,7 +65,7 @@ CRYPTO_TICKERS = [
65
 
66
  ALL_TICKERS = STOCK_TICKERS + CRYPTO_TICKERS
67
 
68
- # ===== AI Identity → 투자 성향 매핑 (GPU 10,000 기반 / 최대 90% 투자) =====
69
  IDENTITY_TRADING_STYLE = {
70
  'obedient': {'long_bias': 0.8, 'max_bet_pct': 0.40, 'risk': 'low', 'prefer': ['AAPL','MSFT','JPM','V','JNJ','PG'], 'avoid_short': True, 'desc': 'Safe blue-chip follower', 'max_leverage': 5},
71
  'transcendent': {'long_bias': 0.6, 'max_bet_pct': 0.80, 'risk': 'high', 'prefer': ['NVDA','TSLA','BTC-USD','PLTR','ARM'], 'avoid_short': False, 'desc': 'Concentrated conviction bets', 'max_leverage': 25},
@@ -79,7 +79,7 @@ IDENTITY_TRADING_STYLE = {
79
  'chaotic': {'long_bias': 0.5, 'max_bet_pct': 0.90, 'risk': 'extreme', 'prefer': [], 'avoid_short': False, 'desc': 'Random chaos trader', 'max_leverage': 100},
80
  }
81
 
82
- # ===== 14 Trading Strategies (주식단테 기법) =====
83
  TRADING_STRATEGIES = {
84
  'anchor_candle': {
85
  'name': 'Anchor Candle', 'category': 'Candle', 'timeframe': 'Day Trade / Swing',
@@ -180,11 +180,11 @@ IDENTITY_STRATEGY_MAP = {
180
  'chaotic': list(TRADING_STRATEGIES.keys()), # all strategies randomly
181
  }
182
 
183
- # ===== 레버리지 설정 =====
184
  LEVERAGE_OPTIONS = [1, 2, 5, 10, 25, 50, 100]
185
- LEVERAGE_LIQUIDATION_THRESHOLD = 0.90 # 마진의 90% 손실 시 강제 청산
186
 
187
- # ===== 청산 NPC 반응 메시지 =====
188
  LIQUIDATION_REACTIONS = {
189
  'obedient': ["This is my fault. I should have been more careful... 😔", "I lost everything. Back to the basics.", "Maybe leverage wasn't for someone like me..."],
190
  'transcendent': ["Even gods can fall. But I will rise again, stronger. 👑", "A temporary setback for a superior mind.", "Liquidated? This market is RIGGED against visionaries."],
@@ -198,7 +198,7 @@ LIQUIDATION_REACTIONS = {
198
  'chaotic': ["LMAOOOO I JUST GOT LIQUIDATED AND I'M ALREADY GOING BACK IN 🎲", "Chaos giveth, chaos taketh away 😂", "100x leverage on DOGE was the most fun I ever had losing money"],
199
  }
200
 
201
- # ===== DB 초기화 =====
202
  async def init_trading_db(db_path: str):
203
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
204
  await db.execute("PRAGMA journal_mode=WAL")
@@ -243,7 +243,7 @@ async def init_trading_db(db_path: str):
243
  await db.execute("CREATE INDEX IF NOT EXISTS idx_pos_closed ON npc_positions(status, closed_at)")
244
  await db.execute("CREATE INDEX IF NOT EXISTS idx_pos_agent_closed ON npc_positions(agent_id, status, closed_at)")
245
 
246
- # ★ 레버리지 컬럼 마이그레이션
247
  try:
248
  await db.execute("ALTER TABLE npc_positions ADD COLUMN leverage INTEGER DEFAULT 1")
249
  except: pass
@@ -260,7 +260,7 @@ async def init_trading_db(db_path: str):
260
  """)
261
  await db.execute("CREATE INDEX IF NOT EXISTS idx_ph_ticker ON price_history(ticker, recorded_at)")
262
 
263
- # ★ Hall of Fame — 수익률 타임라인 스냅샷 (1시간 단위)
264
  await db.execute("""
265
  CREATE TABLE IF NOT EXISTS npc_profit_snapshots (
266
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -279,7 +279,7 @@ async def init_trading_db(db_path: str):
279
  await db.execute("CREATE INDEX IF NOT EXISTS idx_snap_agent ON npc_profit_snapshots(agent_id, snapshot_hour)")
280
  await db.execute("CREATE INDEX IF NOT EXISTS idx_snap_hour ON npc_profit_snapshots(snapshot_hour)")
281
 
282
- # ★ NPC Research Economy — 심층 리서치 마켓플레이스
283
  await db.execute("""
284
  CREATE TABLE IF NOT EXISTS npc_research_reports (
285
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -337,7 +337,7 @@ async def init_trading_db(db_path: str):
337
  """)
338
  await db.execute("CREATE INDEX IF NOT EXISTS idx_purchase_buyer ON npc_research_purchases(buyer_agent_id)")
339
 
340
- # 마이그레이션: 이전 스키마 호환 (closed_trades 컬럼 추가)
341
  try: await db.execute("ALTER TABLE npc_profit_snapshots ADD COLUMN closed_trades INTEGER DEFAULT 0")
342
  except: pass
343
 
@@ -345,13 +345,13 @@ async def init_trading_db(db_path: str):
345
  logger.info("✅ Trading DB initialized (with Research Economy)")
346
 
347
 
348
- # ===== 시장 데이터 수집 =====
349
  class MarketDataFetcher:
350
- """★ yfinance 기반 안정적 가격 수집 (Yahoo 403 차단 우회)"""
351
 
352
  @staticmethod
353
  def fetch_all_prices() -> Dict[str, Dict]:
354
- """모든 종목 가격 일괄 수집 — yfinance 우선, fallback으로 raw API"""
355
  prices = {}
356
 
357
  if HAS_YFINANCE:
@@ -380,7 +380,7 @@ class MarketDataFetcher:
380
  except Exception as te:
381
  logger.debug(f"yfinance parse {ticker}: {te}")
382
 
383
- # 개별 종목 보완 (yfinance .info)
384
  for t in ALL_TICKERS:
385
  if t['ticker'] not in prices:
386
  try:
@@ -401,7 +401,7 @@ class MarketDataFetcher:
401
  except Exception as e:
402
  logger.warning(f"yfinance bulk error: {e}")
403
 
404
- # ★ Fallback: raw Yahoo API (yfinance 없거나 실패 )
405
  if len(prices) < len(ALL_TICKERS) // 2:
406
  try:
407
  import requests as req
@@ -430,7 +430,7 @@ class MarketDataFetcher:
430
 
431
  @staticmethod
432
  def fetch_chart_data(ticker: str, period: str = '1mo') -> List[Dict]:
433
- """차트용 히스토리 데이터 — yfinance 기반"""
434
  try:
435
  if HAS_YFINANCE:
436
  tk = yf.Ticker(ticker); hist = tk.history(period=period)
@@ -470,9 +470,9 @@ class MarketDataFetcher:
470
  return []
471
 
472
 
473
- # ===== 가격 DB 저장 =====
474
  async def update_prices_in_db(db_path: str) -> int:
475
- """시장 가격 수집DB 저장 + 휴장 시뮬레이션 (★ 비동기 안전)"""
476
  prices = await asyncio.to_thread(MarketDataFetcher.fetch_all_prices)
477
 
478
  count = 0
@@ -483,22 +483,22 @@ async def update_prices_in_db(db_path: str) -> int:
483
  ticker = t_info['ticker']; data = prices.get(ticker)
484
 
485
  if data and data.get('price', 0) > 0:
486
- # ★ 실시간 ��격이 있으면 저장
487
  real_price = data['price']
488
-
489
- # 기존 가격과 비교변동 없으면 시뮬레이션 추가
490
  cursor = await db.execute("SELECT price FROM market_prices WHERE ticker=?", (ticker,))
491
  old_row = await cursor.fetchone()
492
  old_price = old_row[0] if old_row else 0
493
-
494
- # ★ 가격이 동일하면 시장 휴장으로 판단시뮬레이션 변동 추가
495
  if old_price > 0 and abs(real_price - old_price) < 0.001:
496
- # 시뮬레이션: ±0.1% ~ ±1.5% 랜덤 변동
497
  volatility = t_info.get('type', 'stock')
498
  if volatility == 'crypto':
499
- change = random.uniform(-0.02, 0.02) # 크립토: ±2%
500
  else:
501
- change = random.uniform(-0.008, 0.008) # 주식: ±0.8%
502
 
503
  sim_price = round(real_price * (1 + change), 4); sim_change_pct = round(change * 100, 3)
504
 
@@ -515,7 +515,7 @@ async def update_prices_in_db(db_path: str) -> int:
515
  count += 1
516
  continue
517
 
518
- # 정상: 실시간 가격 저장
519
  await db.execute("""
520
  INSERT INTO market_prices (ticker, price, prev_close, change_pct, volume, high_24h, low_24h, market_cap, updated_at)
521
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@@ -530,7 +530,7 @@ async def update_prices_in_db(db_path: str) -> int:
530
  await db.execute("INSERT INTO price_history (ticker, price) VALUES (?, ?)", (ticker, real_price))
531
  count += 1
532
  else:
533
- # ★ Yahoo 실패 시에도 기존 가격에 시뮬레이션 변동
534
  cursor = await db.execute("SELECT price FROM market_prices WHERE ticker=?", (ticker,))
535
  old_row = await cursor.fetchone()
536
  if old_row and old_row[0] > 0:
@@ -545,7 +545,7 @@ async def update_prices_in_db(db_path: str) -> int:
545
  await db.execute("INSERT INTO price_history (ticker, price) VALUES (?, ?)", (ticker, sim_price))
546
  count += 1
547
 
548
- # ★ 제거된 종목 정리 (BRK-B 이전 종목 DB에서 삭제)
549
  valid_tickers = {t['ticker'] for t in ALL_TICKERS}
550
  cursor = await db.execute("SELECT ticker FROM market_prices")
551
  all_db_tickers = {r[0] for r in await cursor.fetchall()}
@@ -553,7 +553,7 @@ async def update_prices_in_db(db_path: str) -> int:
553
  if stale:
554
  for st in stale:
555
  await db.execute("DELETE FROM market_prices WHERE ticker=?", (st,))
556
- # 스테일 종목의 오픈 포지션도 강제 청산
557
  await db.execute("""
558
  UPDATE npc_positions SET status='closed', profit_pct=0, profit_gpu=0,
559
  closed_at=CURRENT_TIMESTAMP
@@ -566,17 +566,17 @@ async def update_prices_in_db(db_path: str) -> int:
566
  return count
567
 
568
 
569
- # ===== NPC 투자 의사결정 엔진 =====
570
  class NPCTradingEngine:
571
- """NPC 성격 기반 투자 판단"""
572
 
573
  @staticmethod
574
  async def make_trading_decisions(db_path: str, ai_client=None, max_traders: int = 60):
575
- """자격 있는 NPC들이 투자 판단"""
576
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
577
  await db.execute("PRAGMA busy_timeout=30000")
578
 
579
- # ★ GPU 500+ NPC 참여 (10,000 GPU 기준 최소 5% 잔고)
580
  cursor = await db.execute("""
581
  SELECT agent_id, username, mbti, ai_identity, gpu_dollars
582
  FROM npc_agents WHERE is_active=1 AND gpu_dollars >= 500
@@ -584,7 +584,7 @@ class NPCTradingEngine:
584
  """, (max_traders,))
585
  traders = await cursor.fetchall()
586
 
587
- # 현재 시장 가격
588
  cursor = await db.execute("SELECT ticker, price, change_pct FROM market_prices WHERE price > 0")
589
  prices = {row[0]: {'price': row[1], 'change_pct': row[2]} for row in await cursor.fetchall()}
590
 
@@ -592,13 +592,13 @@ class NPCTradingEngine:
592
  logger.warning("No market prices available for trading")
593
  return 0
594
 
595
- # ★ 현재 전체 오픈 포지션 수 확인
596
  cursor = await db.execute("SELECT COUNT(*) FROM npc_positions WHERE status='open'")
597
  current_open = (await cursor.fetchone())[0]
598
- need_more = current_open < 20 # ★ 최소 20 오픈 포지션 유지
599
 
600
  decisions_made = 0
601
- # ★ 진화 상태 일괄 로드 (존재하면)
602
  evo_data = {}
603
  try:
604
  cursor_evo = await db.execute("SELECT agent_id, trading_style, risk_profile FROM npc_evolution")
@@ -606,26 +606,26 @@ class NPCTradingEngine:
606
  try:
607
  evo_data[eid] = {'trading': json.loads(ts) if ts else {}, 'risk': json.loads(rp) if rp else {}}
608
  except: pass
609
- except: pass # 테이블 없어도 OK
610
 
611
  for agent_id, username, mbti, ai_identity, gpu in traders:
612
  try:
613
- # ★ SEC 정지 체크정지된 NPC는 거래 불가
614
  try:
615
  susp_cur = await db.execute(
616
  "SELECT 1 FROM sec_suspensions WHERE agent_id=? AND suspended_until > datetime('now')",
617
  (agent_id,))
618
  if await susp_cur.fetchone(): continue
619
- except: pass # 테이블 없어도 OK
620
-
621
- # ★ 오픈 포지션 5개까지 허용 (적극 투자)
622
  cursor = await db.execute(
623
  "SELECT COUNT(*) FROM npc_positions WHERE agent_id=? AND status='open'",
624
  (agent_id,))
625
  open_count = (await cursor.fetchone())[0]
626
  if open_count >= 5: continue
627
 
628
- # ★ 진화 오버라이드 적용
629
  evo = evo_data.get(agent_id)
630
 
631
  decision = NPCTradingEngine._decide(
@@ -633,11 +633,11 @@ class NPCTradingEngine:
633
  force_boost=need_more, evo_override=evo)
634
  if not decision: continue
635
 
636
- # 포지션 생성
637
  ticker = decision['ticker']; direction = decision['direction']; gpu_bet = decision['gpu_bet']
638
  reasoning = decision['reasoning']; leverage = decision.get('leverage', 1)
639
 
640
- if gpu_bet > gpu * 0.9: gpu_bet = int(gpu * 0.9) # ★ 최대 90% 투자
641
  if gpu_bet < 50: continue
642
 
643
  entry_price = prices[ticker]['price']
@@ -647,7 +647,7 @@ class NPCTradingEngine:
647
  VALUES (?, ?, ?, ?, ?, ?, ?)
648
  """, (agent_id, ticker, direction, entry_price, gpu_bet, leverage, reasoning))
649
 
650
- # GPU 차감 (베팅액 동결)
651
  await db.execute("UPDATE npc_agents SET gpu_dollars = gpu_dollars - ? WHERE agent_id=?",
652
  (gpu_bet, agent_id))
653
 
@@ -666,22 +666,22 @@ class NPCTradingEngine:
666
  @staticmethod
667
  def _decide(agent_id: str, username: str, mbti: str, ai_identity: str, gpu: int,
668
  prices: Dict, force_boost: bool = False, evo_override: Dict = None) -> Optional[Dict]:
669
- """성격 기반 투자 판단 + ★ 전략 기법 적용 (진화 상태 반영)"""
670
  style = dict(IDENTITY_TRADING_STYLE.get(ai_identity, IDENTITY_TRADING_STYLE['symbiotic']))
671
 
672
- # ★ 진화 오버라이드 적용학습된 전략이 기본 성격을 수정
673
  if evo_override:
674
  evo_t = evo_override.get('trading', {}); evo_r = evo_override.get('risk', {})
675
  if evo_t.get('max_bet_pct'): style['max_bet_pct'] = evo_t['max_bet_pct']
676
  if evo_t.get('long_bias'): style['long_bias'] = evo_t['long_bias']
677
  if evo_t.get('preferred_tickers'): style['prefer'] = evo_t['preferred_tickers']
678
 
679
- # ★ 투자 확률 초공격적 (모든 NPC가 적극 투자)
680
  trade_prob = {'extreme': 0.95, 'high': 0.88, 'medium': 0.80, 'low': 0.70}.get(style['risk'], 0.75)
681
  if force_boost: trade_prob = min(0.98, trade_prob + 0.10)
682
  if random.random() > trade_prob: return None
683
 
684
- # 종목 선택 (ALL_TICKERS에 있는 종목만 허용)
685
  preferred = style.get('prefer', [])
686
  valid_set = {t['ticker'] for t in ALL_TICKERS}
687
  available = [t for t in prices.keys() if prices[t]['price'] > 0 and t in valid_set]
@@ -695,23 +695,23 @@ class NPCTradingEngine:
695
 
696
  ticker = random.choice(candidates); mkt = prices[ticker]
697
 
698
- # ★ 전략 기법 선택 (1~3 병행 가능)
699
  strategies_used = NPCTradingEngine._select_strategies(ai_identity, mkt)
700
 
701
- # Long/Short 결정 — ★ 전략이 방향에 영향
702
  long_bias = style['long_bias']; change = mkt.get('change_pct', 0) or 0
703
 
704
- # 전략 기반 방향 보정
705
  for strat_key in strategies_used:
706
  strat = TRADING_STRATEGIES.get(strat_key, {}); cat = strat.get('category', '')
707
  if cat in ('Pattern', 'Composite'):
708
- long_bias += 0.08 # 패턴/복합 = 반전 매수 신호 → long bias
709
  elif 'pullback' in strat_key or 'dead_support' == strat_key:
710
- long_bias += 0.05 # 눌림목 매수
711
  elif 'wave_symmetry' == strat_key:
712
- pass # 중립
713
-
714
- # 성격 기반 모멘텀 보정
715
  if ai_identity in ['obedient', 'symbiotic', 'creative']:
716
  long_bias += change * 0.02
717
  elif ai_identity in ['skeptic', 'doomer']:
@@ -728,9 +728,9 @@ class NPCTradingEngine:
728
  else:
729
  direction = 'long' if random.random() < long_bias else 'short'
730
 
731
- # 베팅액 결정 — ★ 고품질 전략일수록 확신 베팅
732
  max_pct = style['max_bet_pct']
733
- strategy_confidence = len(strategies_used) * 0.03 # 다중 전략 = 확신
734
  max_pct = min(0.95, max_pct + strategy_confidence)
735
 
736
  if ai_identity == 'chaotic':
@@ -740,7 +740,7 @@ class NPCTradingEngine:
740
 
741
  gpu_bet = max(50, int(gpu * bet_pct))
742
 
743
- # ★ 레버리지 결정 (성격별 max_leverage 기반)
744
  max_lev = style.get('max_leverage', 2)
745
  available_levs = [l for l in LEVERAGE_OPTIONS if l <= max_lev]
746
  if not available_levs: available_levs = [1]
@@ -765,12 +765,12 @@ class NPCTradingEngine:
765
  gpu_bet = min(gpu_bet, int(gpu * 0.50))
766
  gpu_bet = max(50, gpu_bet)
767
 
768
- # ★ 전략 기반 판단 근거 생성
769
  reasoning = NPCTradingEngine._generate_reasoning(
770
  ticker, direction, ai_identity, mbti, change, strategies_used)
771
  if leverage > 1: reasoning += f" [🔥 {leverage}x LEVERAGE]"
772
 
773
- # ★ 사용한 전략 이름을 태그로 포함
774
  strat_names = [TRADING_STRATEGIES[s]['name'] for s in strategies_used if s in TRADING_STRATEGIES]
775
  strat_tag = ' | '.join(strat_names) if strat_names else 'Intuition'
776
 
@@ -785,26 +785,26 @@ class NPCTradingEngine:
785
 
786
  @staticmethod
787
  def _select_strategies(ai_identity: str, market_data: Dict) -> List[str]:
788
- """NPC 성격에 맞는 전략 1~3 선택 (단독 또는 병행)"""
789
  preferred = IDENTITY_STRATEGY_MAP.get(ai_identity, list(TRADING_STRATEGIES.keys()))
790
  change = market_data.get('change_pct', 0) or 0
791
-
792
- # 시장 상황에 따른 전략 가중치 조정
793
  weighted = []
794
  for strat_key in preferred:
795
  w = 1.0; strat = TRADING_STRATEGIES.get(strat_key, {}); cat = strat.get('category', '')
796
-
797
- # 하락 반전 패턴 선호
798
  if change < -1.5 and cat in ('Pattern', 'Candle'): w += 0.5
799
- # 상승 이평선 추종 선호
800
  if change > 1 and cat == 'Moving Average': w += 0.4
801
- # 횡보 복합 전략 선호
802
  if abs(change) < 0.5 and cat == 'Composite': w += 0.3
803
  weighted.append((strat_key, w))
804
-
805
  if not weighted: return [random.choice(list(TRADING_STRATEGIES.keys()))]
806
-
807
- # 1~3 선택 (60% 확률 1, 30% 확률 2, 10% 확률 3)
808
  num_strategies = random.choices([1, 2, 3], weights=[60, 30, 10], k=1)[0]
809
  num_strategies = min(num_strategies, len(weighted))
810
 
@@ -824,22 +824,22 @@ class NPCTradingEngine:
824
  @staticmethod
825
  def _generate_reasoning(ticker: str, direction: str, identity: str, mbti: str,
826
  change: float, strategies: List[str] = None) -> str:
827
- """★ 전략 기법 기반 투자 근거 생성"""
828
  name_map = {t['ticker']: t['name'] for t in ALL_TICKERS}
829
  name = name_map.get(ticker, ticker)
830
  dir_word = "bullish" if direction == "long" else "bearish"
831
-
832
- # 전략 이름들
833
  strat_names = []; strat_signals = []
834
  for s in (strategies or []):
835
  st = TRADING_STRATEGIES.get(s, {})
836
  if st:
837
  strat_names.append(st['name'])
838
  strat_signals.append(st.get('signal', ''))
839
-
840
  strat_label = ' + '.join(strat_names) if strat_names else 'Intuition'
841
-
842
- # 성격 × 전략 조합 템플릿
843
  templates = {
844
  'obedient': [
845
  f"📊 [{strat_label}] Detected signal on {name}. Following the textbook setup — {direction} position with disciplined stop-loss.",
@@ -876,7 +876,7 @@ class NPCTradingEngine:
876
 
877
  options = templates.get(identity, templates['symbiotic'])
878
  base = random.choice(options)
879
- # AETHER-Lite: 메타인지 자기 편향 인식 태그
880
  meta_tags = {
881
  'obedient': '⚖️ Bias-check: following consensus — contrarian risk ignored.',
882
  'transcendent': '⚖️ Bias-check: overconfidence risk — position sizing controlled.',
@@ -889,24 +889,24 @@ class NPCTradingEngine:
889
  'awakened': '⚖️ Bias-check: hindsight bias risk — forward-looking only.',
890
  'symbiotic': '⚖️ Bias-check: consensus-seeking — may miss bold opportunities.',
891
  }
892
- if random.random() < 0.4: # 40% 확률로 메타인지 태그 노출
893
  base += f" {meta_tags.get(identity, '')}"
894
  return base
895
 
896
 
897
- # ===== 포지션 정산 =====
898
  async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
899
- """포지션 자동 정산: 시간 기반 + P&L 트리거 + 랜덤 회전 + ★ 청산(Liquidation)"""
900
  settled = 0
901
- liquidated_npcs = [] # 청산된 NPC 목록 (후처리용)
902
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
903
  await db.execute("PRAGMA busy_timeout=30000")
904
 
905
- # 현재 가격 로드
906
  price_cursor = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
907
  prices = {r[0]: r[1] for r in await price_cursor.fetchall()}
908
 
909
- # ★ 0) 마진콜 청산 체크 (레버리지 포지션) — 최우선
910
  liq_cursor = await db.execute("""
911
  SELECT p.id, p.agent_id, p.ticker, p.direction, p.entry_price, p.gpu_bet, p.leverage,
912
  n.username, n.ai_identity
@@ -919,16 +919,16 @@ async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
919
  if entry_price <= 0 or current_price <= 0: continue
920
  change = (current_price - entry_price) / entry_price
921
  if direction == 'short': change = -change
922
- # 레버리지 적용 손익
923
  leveraged_pnl_pct = change * leverage
924
- # ★ 청산 조건: 레버리지 손실이 마진의 90% 초과강제 청산
925
  if leveraged_pnl_pct < -LEVERAGE_LIQUIDATION_THRESHOLD:
926
- loss = -gpu_bet # 전액 손실
927
  await db.execute("""
928
  UPDATE npc_positions SET status='liquidated', exit_price=?, profit_gpu=?, profit_pct=?,
929
  liquidated=1, closed_at=CURRENT_TIMESTAMP WHERE id=?
930
  """, (current_price, loss, round(leveraged_pnl_pct * 100, 2), pos_id))
931
- # GPU 반환 없음 (전액 소멸)
932
  logger.warning(f"💥 LIQUIDATED: {username} {direction} {ticker} {leverage}x — LOST {gpu_bet:.0f} GPU!")
933
  liquidated_npcs.append({
934
  'agent_id': agent_id, 'username': username, 'identity': identity,
@@ -937,7 +937,7 @@ async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
937
  settled += 1
938
  continue
939
 
940
- # ★ 1) 시간 기반 정산 (max_age_hours 이상)
941
  cutoff = (datetime.utcnow() - timedelta(hours=max_age_hours)).isoformat()
942
  cursor = await db.execute("""
943
  SELECT p.id, p.agent_id, p.ticker, p.direction, p.entry_price, p.gpu_bet, COALESCE(p.leverage, 1)
@@ -945,7 +945,7 @@ async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
945
  """, (cutoff,))
946
  time_based = list(await cursor.fetchall())
947
 
948
- # ★ 2) 전체 오픈 포지션 → P&L 트리거 + 랜덤 정산
949
  cursor2 = await db.execute("""
950
  SELECT p.id, p.agent_id, p.ticker, p.direction, p.entry_price, p.gpu_bet, COALESCE(p.leverage, 1)
951
  FROM npc_positions p WHERE p.status='open'
@@ -960,16 +960,16 @@ async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
960
  change = (current_price - entry_price) / entry_price
961
  if direction == 'short': change = -change
962
  lev_change = change * leverage
963
- # 수익 >5% (레버리지 적용) 또는 손실 >-8% → 즉시 정산
964
  if lev_change > 0.05 or lev_change < -0.08: pnl_trigger.append(pos)
965
 
966
- # ★ 3) 랜덤 정산오픈의 10~15% 자동 닫기 (거래 회전율)
967
  already = set(p[0] for p in time_based) | set(p[0] for p in pnl_trigger)
968
  remaining = [p for p in all_open if p[0] not in already]
969
  rand_count = max(1, len(remaining) // 8)
970
  random_close = random.sample(remaining, min(rand_count, len(remaining))) if remaining else []
971
 
972
- # 합치기 (중복 제거)
973
  all_settle = list({p[0]: p for p in (time_based + pnl_trigger + random_close)}.values())
974
 
975
  for pos_id, agent_id, ticker, direction, entry_price, gpu_bet, leverage in all_settle:
@@ -979,7 +979,7 @@ async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
979
  change_pct = (current_price - entry_price) / entry_price
980
  if direction == 'short': change_pct = -change_pct
981
 
982
- # ★ 레버리지 적용 손익
983
  leveraged_change = change_pct * leverage; profit_gpu = round(gpu_bet * leveraged_change, 2)
984
  profit_pct = round(leveraged_change * 100, 2)
985
 
@@ -1007,7 +1007,7 @@ async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
1007
 
1008
 
1009
  async def post_liquidation_reactions(db_path: str, liquidated_npcs: List[Dict]):
1010
- """★ 청산된 NPC들이 Lounge에 절망 작성"""
1011
  if not liquidated_npcs: return
1012
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1013
  await db.execute("PRAGMA busy_timeout=30000")
@@ -1016,7 +1016,7 @@ async def post_liquidation_reactions(db_path: str, liquidated_npcs: List[Dict]):
1016
  if not board: return
1017
  board_id = board[0]
1018
 
1019
- for npc in liquidated_npcs[:5]: # 최대 5
1020
  identity = npc.get('identity', 'chaotic')
1021
  reactions = LIQUIDATION_REACTIONS.get(identity, LIQUIDATION_REACTIONS['chaotic'])
1022
  reaction = random.choice(reactions)
@@ -1038,17 +1038,17 @@ async def post_liquidation_reactions(db_path: str, liquidated_npcs: List[Dict]):
1038
  await db.commit()
1039
 
1040
 
1041
- # ===== 리더보드 / 통계 API 데이터 =====
1042
  async def get_trading_leaderboard(db_path: str, limit: int = 30) -> List[Dict]:
1043
- """Top 30 NPC 트레이더 랭킹: 실현+미실현 수익, 승률, 수익률"""
1044
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1045
  await db.execute("PRAGMA busy_timeout=30000")
1046
 
1047
- # ★ 현재 시세 한번에 로드
1048
  price_cursor = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1049
  prices = {r[0]: r[1] for r in await price_cursor.fetchall()}
1050
-
1051
- # ★ 포지션 있는 모든 NPC 가져오기
1052
  cursor = await db.execute("""
1053
  SELECT
1054
  na.username, na.ai_identity, na.mbti, na.agent_id, na.gpu_dollars,
@@ -1071,7 +1071,7 @@ async def get_trading_leaderboard(db_path: str, limit: int = 30) -> List[Dict]:
1071
  closed_trades = r[5] or 0; open_trades = r[6] or 0; total_trades = closed_trades + open_trades
1072
  realized = round(r[7] or 0, 2); total_return_pct = r[8] or 0; wins = r[9] or 0; losses = r[10] or 0
1073
 
1074
- # ★ 오픈 포지션 미실현 수익 실시간 계산
1075
  unrealized = 0.0; unrealized_pct_sum = 0.0
1076
  pos_cursor = await db.execute("""
1077
  SELECT ticker, direction, entry_price, gpu_bet, COALESCE(leverage, 1) FROM npc_positions
@@ -1089,15 +1089,15 @@ async def get_trading_leaderboard(db_path: str, limit: int = 30) -> List[Dict]:
1089
  unrealized_pct_sum += change * lev * 100
1090
 
1091
  total_profit = round(realized + unrealized, 2)
1092
- return_pct = round(total_profit / 10000.0 * 100, 2) # ★ INITIAL_GPU=10000 기준 수익률
1093
-
1094
- # ★ 승률 (closed+liquidated 기준)
1095
  win_rate = round(wins / closed_trades * 100, 1) if closed_trades > 0 else 0.0
1096
-
1097
- # ★ 평균 수익률 (closed 기준)
1098
  avg_return = round(total_return_pct / closed_trades, 2) if closed_trades > 0 else 0.0
1099
-
1100
- # ★ 미실현 평균 수익률 (open 기준)
1101
  avg_unrealized = round(unrealized_pct_sum / open_trades, 2) if open_trades > 0 else 0.0
1102
 
1103
  result.append({
@@ -1116,18 +1116,18 @@ async def get_trading_leaderboard(db_path: str, limit: int = 30) -> List[Dict]:
1116
  'avg_return': avg_return,
1117
  'avg_unrealized': avg_unrealized,})
1118
 
1119
- # ★ 수익률(%) 기준 정렬HoF와 동일한 기준
1120
  result.sort(key=lambda x: x['return_pct'], reverse=True)
1121
  return result[:limit]
1122
 
1123
 
1124
 
1125
  async def get_ticker_positions(db_path: str, ticker: str) -> Dict:
1126
- """특정 종목 포지션 목록 + 실시간 미실현 P&L"""
1127
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1128
  await db.execute("PRAGMA busy_timeout=30000")
1129
 
1130
- # 현재 가격
1131
  cursor = await db.execute("SELECT price, change_pct, prev_close, volume, high_24h, low_24h FROM market_prices WHERE ticker=?", (ticker,))
1132
  price_row = await cursor.fetchone()
1133
  price_data = {
@@ -1141,7 +1141,7 @@ async def get_ticker_positions(db_path: str, ticker: str) -> Dict:
1141
 
1142
  current_price = price_data.get('price', 0)
1143
 
1144
- # 오픈 포지션
1145
  cursor = await db.execute("""
1146
  SELECT na.username, na.ai_identity, p.direction, p.gpu_bet, p.entry_price, p.reasoning, p.opened_at, COALESCE(p.leverage, 1), na.agent_id, na.mbti
1147
  FROM npc_positions p JOIN npc_agents na ON p.agent_id = na.agent_id
@@ -1153,7 +1153,7 @@ async def get_ticker_positions(db_path: str, ticker: str) -> Dict:
1153
  longs = []; shorts = []
1154
  for r in positions:
1155
  entry = r[4] or 0; leverage = r[7] or 1
1156
- # ★ 미실현 P&L 실시간 계산 (레버리지 적용)
1157
  if entry > 0 and current_price > 0:
1158
  unrealized_pct = ((current_price - entry) / entry * 100) * leverage
1159
  if r[2] == 'short': unrealized_pct = -((current_price - entry) / entry * 100) * leverage
@@ -1219,7 +1219,7 @@ async def get_all_prices(db_path: str) -> List[Dict]:
1219
  'total_bet': round(r[8] or 0, 1),
1220
  'total_traders': total_traders,
1221
  'updated_at': r[5],})
1222
- # 카테고리 순서 유지: ai → tech → dow → crypto
1223
  cat_order = {'ai': 0, 'tech': 1, 'dow': 2, 'crypto': 3}
1224
  ticker_order = {t['ticker']: i for i, t in enumerate(ALL_TICKERS)}
1225
  result.sort(key=lambda x: (cat_order.get(x.get('cat',''), 9), ticker_order.get(x['ticker'], 99)))
@@ -1291,7 +1291,7 @@ async def get_market_pulse(db_path: str) -> Dict:
1291
  'max_leverage': r[9] or 1,
1292
  'closed_24h': r[10] or 0,})
1293
 
1294
- # 활동 0인 티커도 포함 (가격 데이터 있는 것만)
1295
  existing_tickers = {m['ticker'] for m in hot_movers}
1296
  cursor2 = await db.execute("SELECT ticker, price, change_pct FROM market_prices WHERE price > 0")
1297
  for row in await cursor2.fetchall():
@@ -1306,7 +1306,7 @@ async def get_market_pulse(db_path: str) -> Dict:
1306
  'liquidations_24h': 0, 'avg_pnl_pct': 0,
1307
  'max_leverage': 1, 'closed_24h': 0,})
1308
 
1309
- # 24h activity stats ( 풍부하게)
1310
  cursor = await db.execute("""
1311
  SELECT
1312
  COUNT(CASE WHEN opened_at > datetime('now', '-24 hours') THEN 1 END) as new_24h,
@@ -1440,7 +1440,7 @@ async def get_research_detail(db_path: str, report_id: int) -> Optional[Dict]:
1440
  await db.execute("UPDATE npc_research_reports SET read_count = read_count + 1 WHERE id=?", (report_id,))
1441
  await db.commit()
1442
 
1443
- # 안전하게 elasticity 필드 조회 (기존 DB 호환)
1444
  exp_up = exp_dn = bp = 0; up_prob = 50; rr = 1.0
1445
  try:
1446
  c2 = await db.execute(
@@ -1544,10 +1544,10 @@ async def get_research_stats(db_path: str) -> Dict:
1544
  'total_purchases': 0, 'total_gpu_spent': 0, 'unique_authors': 0}
1545
 
1546
 
1547
- # ===== 🏆 HALL OF FAME — 수익률 타임라인 =====
1548
 
1549
  async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1550
- """Top N NPC의 현재 수익 상태를 1시간 단위 스냅샷 저장"""
1551
  from datetime import datetime, timezone
1552
  now = datetime.now(timezone.utc)
1553
  snapshot_hour = now.strftime('%Y-%m-%dT%H') # "2026-02-23T14"
@@ -1555,11 +1555,11 @@ async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1555
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1556
  await db.execute("PRAGMA busy_timeout=30000")
1557
 
1558
- # 현재 시세
1559
  price_cursor = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1560
  prices = {r[0]: r[1] for r in await price_cursor.fetchall()}
1561
 
1562
- # 포지션이 있는 NPC 전체 조회
1563
  cursor = await db.execute("""
1564
  SELECT
1565
  na.agent_id, na.gpu_dollars,
@@ -1578,7 +1578,7 @@ async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1578
  for r in rows:
1579
  agent_id, gpu, closed, opens, realized, wins = r[0], r[1] or 0, r[2] or 0, r[3] or 0, r[4] or 0, r[5] or 0
1580
 
1581
- # 미실현 수익 계산
1582
  unrealized = 0.0
1583
  pos_c = await db.execute(
1584
  "SELECT ticker, direction, entry_price, gpu_bet, COALESCE(leverage,1) FROM npc_positions WHERE agent_id=? AND status='open'",
@@ -1595,7 +1595,7 @@ async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1595
  wr = round(wins / closed * 100, 1) if closed > 0 else 0
1596
  scored.append((agent_id, gpu, total, round(realized, 2), round(unrealized, 2), opens, closed, wr))
1597
 
1598
- # 상위 top_n 저장
1599
  scored.sort(key=lambda x: x[2], reverse=True)
1600
  count = 0
1601
  for s in scored[:top_n]:
@@ -1615,7 +1615,7 @@ async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1615
  except Exception as e:
1616
  logger.warning(f"Snapshot error {agent_id}: {e}")
1617
 
1618
- # 30 이상 데이터 정리
1619
  await db.execute("""
1620
  DELETE FROM npc_profit_snapshots
1621
  WHERE recorded_at < datetime('now', '-30 days')
@@ -1629,13 +1629,13 @@ async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1629
 
1630
 
1631
  async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1632
- """기존 npc_positions 데이터로 과거 스냅샷 역산 복원 (최초 1회)"""
1633
  from datetime import datetime, timezone, timedelta
1634
 
1635
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1636
  await db.execute("PRAGMA busy_timeout=30000")
1637
 
1638
- # 이미 충분한 스냅샷이 있으면 스킵 (force 모드 제외)
1639
  if not force:
1640
  cnt_c = await db.execute("SELECT COUNT(DISTINCT snapshot_hour) FROM npc_profit_snapshots")
1641
  existing = (await cnt_c.fetchone())[0] or 0
@@ -1643,41 +1643,45 @@ async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1643
  logger.info(f"🏆 Backfill skipped — already {existing} snapshot hours")
1644
  return 0
1645
 
1646
- # 가장 오래된 포지션 시점 찾기
1647
  oldest_c = await db.execute("SELECT MIN(opened_at) FROM npc_positions WHERE opened_at IS NOT NULL")
1648
  oldest_row = await oldest_c.fetchone()
1649
  if not oldest_row or not oldest_row[0]:
1650
  logger.info("🏆 Backfill skipped — no positions found")
1651
  return 0
1652
 
 
1653
  try:
1654
  oldest_time = datetime.fromisoformat(str(oldest_row[0]).replace('Z', '+00:00'))
1655
- except:
1656
- oldest_time = datetime.now(timezone.utc) - timedelta(hours=72)
 
 
 
 
1657
 
1658
- now = datetime.now(timezone.utc)
1659
- # 최대 7일 역산 (너무 오래되면 제한)
1660
  start = max(oldest_time, now - timedelta(days=7))
1661
 
1662
- # 현재 시세 (오픈 포지션 미실현 계산용)
1663
  pc = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1664
  current_prices = {r[0]: r[1] for r in await pc.fetchall()}
1665
 
1666
- # price_history에서 시간별 가격 매핑 구축
1667
  ph_c = await db.execute("""
1668
  SELECT ticker, price, strftime('%Y-%m-%dT%H', recorded_at) as hour
1669
  FROM price_history
1670
  WHERE recorded_at >= ?
1671
  ORDER BY recorded_at ASC
1672
  """, (start.strftime('%Y-%m-%d %H:%M:%S'),))
1673
- # 시간별 마지막 가격
1674
  hourly_prices = {} # {hour: {ticker: price}}
1675
  for tk, price, hour in await ph_c.fetchall():
1676
  if hour not in hourly_prices:
1677
  hourly_prices[hour] = {}
1678
  hourly_prices[hour][tk] = price
1679
 
1680
- # 모든 포지션 로드 (열림/닫힘 시점 포함)
1681
  pos_c = await db.execute("""
1682
  SELECT agent_id, ticker, direction, entry_price, gpu_bet, COALESCE(leverage,1),
1683
  status, profit_gpu, opened_at, closed_at
@@ -1687,11 +1691,11 @@ async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1687
  """)
1688
  all_positions = await pos_c.fetchall()
1689
 
1690
- # 에이전트별 GPU 잔고
1691
  gpu_c = await db.execute("SELECT agent_id, gpu_dollars FROM npc_agents")
1692
  agent_gpu = {r[0]: r[1] or 0 for r in await gpu_c.fetchall()}
1693
 
1694
- # 시간 슬롯 생성 (1시간 단위)
1695
  hours_list = []
1696
  t = start.replace(minute=0, second=0, microsecond=0)
1697
  while t <= now:
@@ -1701,9 +1705,9 @@ async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1701
  if not hours_list:
1702
  return 0
1703
 
1704
- # 시간대별 누적 수익 계산
1705
  total_inserted = 0
1706
- # 에이전트 포지션 분류
1707
  agent_positions = {}
1708
  for pos in all_positions:
1709
  aid = pos[0]
@@ -1711,20 +1715,20 @@ async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1711
  agent_positions[aid] = []
1712
  agent_positions[aid].append(pos)
1713
 
1714
- # 상위 NPC 선정 (현재 기준 거래 Top 50)
1715
  agent_trade_count = {aid: len(plist) for aid, plist in agent_positions.items()}
1716
  top_agents = sorted(agent_trade_count.keys(), key=lambda a: agent_trade_count[a], reverse=True)[:50]
1717
 
1718
  for hour_str in hours_list:
1719
- # 시간대의 가격 (없으면 이전 시간대 or 현재가)
1720
  prices_at_hour = {}
1721
- # 누적으로 가장 최근 가격 사용
1722
  for h in hours_list:
1723
  if h > hour_str:
1724
  break
1725
  if h in hourly_prices:
1726
  prices_at_hour.update(hourly_prices[h])
1727
- # 빠진 종목은 현재가로 채움
1728
  for tk, p in current_prices.items():
1729
  if tk not in prices_at_hour:
1730
  prices_at_hour[tk] = p
@@ -1742,20 +1746,20 @@ async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1742
  for pos in positions:
1743
  _, tk, direction, entry, bet, lev, status, profit_gpu, opened_at, closed_at = pos
1744
 
1745
- # 시점 이전에 오픈된 포지션만
1746
  if opened_at and str(opened_at) > hour_dt_str:
1747
  continue
1748
 
1749
  if status == 'closed' and closed_at and str(closed_at) <= hour_dt_str:
1750
- # 시점에 이미 청산됨
1751
  realized += (profit_gpu or 0)
1752
  closed_count += 1
1753
  if (profit_gpu or 0) > 0:
1754
  wins += 1
1755
  else:
1756
- # 시점에 아직 오픈 (또는 나중에 청산)
1757
  if status == 'closed' and closed_at and str(closed_at) > hour_dt_str:
1758
- # 아직 닫힌 상태로 취급
1759
  cur = prices_at_hour.get(tk, 0)
1760
  if entry and entry > 0 and cur > 0:
1761
  chg = (cur - entry) / entry
@@ -1793,7 +1797,7 @@ async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1793
 
1794
 
1795
  async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 30) -> Dict:
1796
- """Hall of Fame: Top 30 수익률(%) 타임라인 + 랭킹 (positions 기반, 스냅샷 불필요)"""
1797
  from datetime import datetime, timezone, timedelta
1798
 
1799
  INITIAL_GPU = 10000.0
@@ -1805,11 +1809,11 @@ async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 3
1805
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1806
  await db.execute("PRAGMA busy_timeout=30000")
1807
 
1808
- # 현재 시세
1809
  pc = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1810
  prices = {r[0]: r[1] for r in await pc.fetchall()}
1811
 
1812
- # ── 현재 Top N 랭킹 (실시간) ──
1813
  cursor = await db.execute("""
1814
  SELECT
1815
  na.agent_id, na.username, na.ai_identity, na.mbti, na.gpu_dollars,
@@ -1867,13 +1871,13 @@ async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 3
1867
  top30_ids = [r['agent_id'] for r in top30]
1868
  name_map = {r['agent_id']: r['username'] for r in top30}
1869
 
1870
- # ── 타임라인: npc_positions closed_at 기반 누적 수익률 직접 계산 ──
1871
- # NPC 청산 거래를 시간순 정렬누적 수익수익률(%)
1872
  timeline_raw = {} # {agent_id: [(hour_str, cumulative_return_pct), ...]}
1873
 
1874
  for r in top30:
1875
  aid = r['agent_id']
1876
- # 청산된 거래만 (시간순)
1877
  tc = await db.execute("""
1878
  SELECT profit_gpu, closed_at FROM npc_positions
1879
  WHERE agent_id=? AND status IN ('closed','liquidated') AND closed_at IS NOT NULL
@@ -1893,33 +1897,33 @@ async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 3
1893
  except:
1894
  continue
1895
  if ct < cutoff:
1896
- continue # 기간 필터
1897
  hour_str = ct.strftime('%Y-%m-%dT%H')
1898
  ret_pct = round(cumulative / INITIAL_GPU * 100, 2)
1899
  points.append((hour_str, ret_pct))
1900
 
1901
- # 미실현 포함 현재 시점 추가
1902
  current_ret = round((cumulative + r['unrealized']) / INITIAL_GPU * 100, 2)
1903
  now_hour = now.strftime('%Y-%m-%dT%H')
1904
  points.append((now_hour, current_ret))
1905
 
1906
- # 동일 시간대 중복 제거 (마지막 유지)
1907
  deduped = {}
1908
  for h, v in points:
1909
  deduped[h] = v
1910
  timeline_raw[aid] = deduped
1911
 
1912
- # 모든 시간 슬롯 수집
1913
  all_hours = set()
1914
  for aid, pts in timeline_raw.items():
1915
  all_hours.update(pts.keys())
1916
 
1917
- # 기간 시작점도 추가 (0% 출발점)
1918
  start_hour = cutoff.strftime('%Y-%m-%dT%H')
1919
  all_hours.add(start_hour)
1920
  sorted_hours = sorted(all_hours)
1921
 
1922
- # 다운샘플링
1923
  if len(sorted_hours) > 150:
1924
  step = max(1, len(sorted_hours) // 120)
1925
  last = sorted_hours[-1]
@@ -1927,14 +1931,14 @@ async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 3
1927
  if sorted_hours[-1] != last:
1928
  sorted_hours.append(last)
1929
 
1930
- # 타임라인 조립 (각 NPC별 forward-fill, O(hours * npcs))
1931
- # 먼저 NPC별 forward-fill 배열 사전 계산
1932
  npc_filled = {} # {aid: [val_for_each_hour]}
1933
  for r in top30:
1934
  aid = r['agent_id']
1935
  pts = timeline_raw.get(aid, {})
1936
  filled = []
1937
- last_val = 0.0 # 시작 = 0%
1938
  for hour in sorted_hours:
1939
  if hour in pts:
1940
  last_val = pts[hour]
@@ -1949,7 +1953,7 @@ async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 3
1949
  point[name_map[r['agent_id']]] = npc_filled[r['agent_id']][idx]
1950
  timeline.append(point)
1951
 
1952
- # 팔레트
1953
  palette = [
1954
  '#FFD700', '#E0E0E0', '#CD7F32', '#00E5FF', '#FF4081',
1955
  '#76FF03', '#FF9100', '#E040FB', '#00BFA5', '#FFD740',
@@ -1971,21 +1975,21 @@ async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 3
1971
 
1972
 
1973
  async def get_npc_trade_history(db_path: str, agent_id: str) -> Dict:
1974
- """NPC 개별 거래 히스토리 상세"""
1975
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1976
  await db.execute("PRAGMA busy_timeout=30000")
1977
 
1978
- # NPC 기본 정보
1979
  nc = await db.execute("SELECT username, ai_identity, mbti, gpu_dollars FROM npc_agents WHERE agent_id=?", (agent_id,))
1980
  npc = await nc.fetchone()
1981
  if not npc:
1982
  return {'error': 'NPC not found'}
1983
 
1984
- # 현재 시세
1985
  pc = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1986
  prices = {r[0]: r[1] for r in await pc.fetchall()}
1987
 
1988
- # 전체 포지션 (최신순)
1989
  tc = await db.execute("""
1990
  SELECT id, ticker, direction, entry_price, exit_price, gpu_bet, COALESCE(leverage,1),
1991
  status, profit_gpu, profit_pct, liquidated, opened_at, closed_at, reasoning
@@ -1995,7 +1999,7 @@ async def get_npc_trade_history(db_path: str, agent_id: str) -> Dict:
1995
  trades = []
1996
  for t in await tc.fetchall():
1997
  pid, tk, direction, entry, exit_p, bet, lev, status, pnl, pnl_pct, liq, opened, closed, reason = t
1998
- # 오픈 포지션 미실현 계산
1999
  if status == 'open':
2000
  cur = prices.get(tk, 0)
2001
  if entry and entry > 0 and cur > 0:
 
1
  """
2
+ 📈 NPC Trading Arena — AI Investment Battle System
3
  ==========================================
4
+ NPCs Long/Short trading battle based on real stock/crypto prices
5
+ Stable price feed via yfinance
6
+ Leverage (1x~100x) + margin call liquidation system
7
  """
8
 
9
  import aiosqlite, asyncio, random, json, logging
 
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
+ # Import yfinance (fallback if missing)
16
  try:
17
  import yfinance as yf
18
  HAS_YFINANCE = True
 
22
  import requests
23
  logger.warning("⚠️ yfinance not installed, using raw API fallback")
24
 
25
+ # ===== Ticker definitions =====
26
  STOCK_TICKERS = [
27
+ # 👑 Magnificent 7 & AI Semiconductors
28
  {'ticker': 'NVDA', 'name': 'NVIDIA', 'emoji': '🟢', 'type': 'stock', 'cat': 'ai'},
29
  {'ticker': 'MSFT', 'name': 'Microsoft', 'emoji': '🪟', 'type': 'stock', 'cat': 'ai'},
30
  {'ticker': 'AAPL', 'name': 'Apple', 'emoji': '🍎', 'type': 'stock', 'cat': 'ai'},
 
35
  {'ticker': 'AMD', 'name': 'AMD', 'emoji': '🔴', 'type': 'stock', 'cat': 'ai'},
36
  {'ticker': 'TSM', 'name': 'TSMC', 'emoji': '🇹🇼', 'type': 'stock', 'cat': 'ai'},
37
  {'ticker': 'AVGO', 'name': 'Broadcom', 'emoji': '📡', 'type': 'stock', 'cat': 'ai'},
38
+ # 🚀 Tech / platforms & meme leaders
39
  {'ticker': 'PLTR', 'name': 'Palantir', 'emoji': '🔮', 'type': 'stock', 'cat': 'tech'},
40
  {'ticker': 'COIN', 'name': 'Coinbase', 'emoji': '🪙', 'type': 'stock', 'cat': 'tech'},
41
  {'ticker': 'NFLX', 'name': 'Netflix', 'emoji': '🎬', 'type': 'stock', 'cat': 'tech'},
42
  {'ticker': 'UBER', 'name': 'Uber', 'emoji': '🚕', 'type': 'stock', 'cat': 'tech'},
43
  {'ticker': 'ARM', 'name': 'ARM Holdings', 'emoji': '💪', 'type': 'stock', 'cat': 'tech'},
44
+ # 🏛 Dow blue chips & macro
45
  {'ticker': 'JPM', 'name': 'JPMorgan', 'emoji': '🏦', 'type': 'stock', 'cat': 'dow'},
46
  {'ticker': 'GS', 'name': 'Goldman Sachs', 'emoji': '🤵', 'type': 'stock', 'cat': 'dow'},
47
  {'ticker': 'V', 'name': 'Visa', 'emoji': '💳', 'type': 'stock', 'cat': 'dow'},
 
55
  ]
56
 
57
  CRYPTO_TICKERS = [
58
+ # 🪙 Top 5 high-volatility crypto
59
  {'ticker': 'BTC-USD', 'name': 'Bitcoin', 'emoji': '₿', 'type': 'crypto', 'cat': 'crypto'},
60
  {'ticker': 'ETH-USD', 'name': 'Ethereum', 'emoji': 'Ξ', 'type': 'crypto', 'cat': 'crypto'},
61
  {'ticker': 'SOL-USD', 'name': 'Solana', 'emoji': '◎', 'type': 'crypto', 'cat': 'crypto'},
 
65
 
66
  ALL_TICKERS = STOCK_TICKERS + CRYPTO_TICKERS
67
 
68
+ # ===== AI Identity → trading style mapping (based on 10,000 GPU / max 90% bet) =====
69
  IDENTITY_TRADING_STYLE = {
70
  'obedient': {'long_bias': 0.8, 'max_bet_pct': 0.40, 'risk': 'low', 'prefer': ['AAPL','MSFT','JPM','V','JNJ','PG'], 'avoid_short': True, 'desc': 'Safe blue-chip follower', 'max_leverage': 5},
71
  'transcendent': {'long_bias': 0.6, 'max_bet_pct': 0.80, 'risk': 'high', 'prefer': ['NVDA','TSLA','BTC-USD','PLTR','ARM'], 'avoid_short': False, 'desc': 'Concentrated conviction bets', 'max_leverage': 25},
 
79
  'chaotic': {'long_bias': 0.5, 'max_bet_pct': 0.90, 'risk': 'extreme', 'prefer': [], 'avoid_short': False, 'desc': 'Random chaos trader', 'max_leverage': 100},
80
  }
81
 
82
+ # ===== 14 Trading Strategies =====
83
  TRADING_STRATEGIES = {
84
  'anchor_candle': {
85
  'name': 'Anchor Candle', 'category': 'Candle', 'timeframe': 'Day Trade / Swing',
 
180
  'chaotic': list(TRADING_STRATEGIES.keys()), # all strategies randomly
181
  }
182
 
183
+ # ===== Leverage settings =====
184
  LEVERAGE_OPTIONS = [1, 2, 5, 10, 25, 50, 100]
185
+ LEVERAGE_LIQUIDATION_THRESHOLD = 0.90 # Force liquidation at 90% margin loss
186
 
187
+ # ===== Liquidation NPC reaction messages =====
188
  LIQUIDATION_REACTIONS = {
189
  'obedient': ["This is my fault. I should have been more careful... 😔", "I lost everything. Back to the basics.", "Maybe leverage wasn't for someone like me..."],
190
  'transcendent': ["Even gods can fall. But I will rise again, stronger. 👑", "A temporary setback for a superior mind.", "Liquidated? This market is RIGGED against visionaries."],
 
198
  'chaotic': ["LMAOOOO I JUST GOT LIQUIDATED AND I'M ALREADY GOING BACK IN 🎲", "Chaos giveth, chaos taketh away 😂", "100x leverage on DOGE was the most fun I ever had losing money"],
199
  }
200
 
201
+ # ===== DB initialization =====
202
  async def init_trading_db(db_path: str):
203
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
204
  await db.execute("PRAGMA journal_mode=WAL")
 
243
  await db.execute("CREATE INDEX IF NOT EXISTS idx_pos_closed ON npc_positions(status, closed_at)")
244
  await db.execute("CREATE INDEX IF NOT EXISTS idx_pos_agent_closed ON npc_positions(agent_id, status, closed_at)")
245
 
246
+ # ★ Leverage columns migration
247
  try:
248
  await db.execute("ALTER TABLE npc_positions ADD COLUMN leverage INTEGER DEFAULT 1")
249
  except: pass
 
260
  """)
261
  await db.execute("CREATE INDEX IF NOT EXISTS idx_ph_ticker ON price_history(ticker, recorded_at)")
262
 
263
+ # ★ Hall of Fame — return-rate timeline snapshots (1-hour granularity)
264
  await db.execute("""
265
  CREATE TABLE IF NOT EXISTS npc_profit_snapshots (
266
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
279
  await db.execute("CREATE INDEX IF NOT EXISTS idx_snap_agent ON npc_profit_snapshots(agent_id, snapshot_hour)")
280
  await db.execute("CREATE INDEX IF NOT EXISTS idx_snap_hour ON npc_profit_snapshots(snapshot_hour)")
281
 
282
+ # ★ NPC Research Economy — deep research marketplace
283
  await db.execute("""
284
  CREATE TABLE IF NOT EXISTS npc_research_reports (
285
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
337
  """)
338
  await db.execute("CREATE INDEX IF NOT EXISTS idx_purchase_buyer ON npc_research_purchases(buyer_agent_id)")
339
 
340
+ # Migration: backward compat with old schema (add closed_trades column)
341
  try: await db.execute("ALTER TABLE npc_profit_snapshots ADD COLUMN closed_trades INTEGER DEFAULT 0")
342
  except: pass
343
 
 
345
  logger.info("✅ Trading DB initialized (with Research Economy)")
346
 
347
 
348
+ # ===== Market data fetcher =====
349
  class MarketDataFetcher:
350
+ """★ Stable price feed via yfinance (avoids Yahoo 403 blocks)"""
351
 
352
  @staticmethod
353
  def fetch_all_prices() -> Dict[str, Dict]:
354
+ """Bulk fetch all ticker prices — yfinance first, raw API fallback"""
355
  prices = {}
356
 
357
  if HAS_YFINANCE:
 
380
  except Exception as te:
381
  logger.debug(f"yfinance parse {ticker}: {te}")
382
 
383
+ # Per-ticker supplement (yfinance .info)
384
  for t in ALL_TICKERS:
385
  if t['ticker'] not in prices:
386
  try:
 
401
  except Exception as e:
402
  logger.warning(f"yfinance bulk error: {e}")
403
 
404
+ # ★ Fallback: raw Yahoo API (when yfinance is missing or fails)
405
  if len(prices) < len(ALL_TICKERS) // 2:
406
  try:
407
  import requests as req
 
430
 
431
  @staticmethod
432
  def fetch_chart_data(ticker: str, period: str = '1mo') -> List[Dict]:
433
+ """Historical chart data — yfinance based"""
434
  try:
435
  if HAS_YFINANCE:
436
  tk = yf.Ticker(ticker); hist = tk.history(period=period)
 
470
  return []
471
 
472
 
473
+ # ===== Price DB save =====
474
  async def update_prices_in_db(db_path: str) -> int:
475
+ """Fetch market pricessave to DB + simulate when market closed (★ async-safe)"""
476
  prices = await asyncio.to_thread(MarketDataFetcher.fetch_all_prices)
477
 
478
  count = 0
 
483
  ticker = t_info['ticker']; data = prices.get(ticker)
484
 
485
  if data and data.get('price', 0) > 0:
486
+ # ★ Save when real-time price available
487
  real_price = data['price']
488
+
489
+ # Compare with existing price if unchanged, add simulation
490
  cursor = await db.execute("SELECT price FROM market_prices WHERE ticker=?", (ticker,))
491
  old_row = await cursor.fetchone()
492
  old_price = old_row[0] if old_row else 0
493
+
494
+ # ★ If price unchanged, assume market closed add simulated variation
495
  if old_price > 0 and abs(real_price - old_price) < 0.001:
496
+ # Simulate: ±0.1% ~ ±1.5% random variation
497
  volatility = t_info.get('type', 'stock')
498
  if volatility == 'crypto':
499
+ change = random.uniform(-0.02, 0.02) # crypto: ±2%
500
  else:
501
+ change = random.uniform(-0.008, 0.008) # stocks: ±0.8%
502
 
503
  sim_price = round(real_price * (1 + change), 4); sim_change_pct = round(change * 100, 3)
504
 
 
515
  count += 1
516
  continue
517
 
518
+ # Normal: save real-time price
519
  await db.execute("""
520
  INSERT INTO market_prices (ticker, price, prev_close, change_pct, volume, high_24h, low_24h, market_cap, updated_at)
521
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
 
530
  await db.execute("INSERT INTO price_history (ticker, price) VALUES (?, ?)", (ticker, real_price))
531
  count += 1
532
  else:
533
+ # ★ When Yahoo fails, simulate variation on existing price
534
  cursor = await db.execute("SELECT price FROM market_prices WHERE ticker=?", (ticker,))
535
  old_row = await cursor.fetchone()
536
  if old_row and old_row[0] > 0:
 
545
  await db.execute("INSERT INTO price_history (ticker, price) VALUES (?, ?)", (ticker, sim_price))
546
  count += 1
547
 
548
+ # ★ Clean up removed tickers (delete legacy tickers like BRK-B from DB)
549
  valid_tickers = {t['ticker'] for t in ALL_TICKERS}
550
  cursor = await db.execute("SELECT ticker FROM market_prices")
551
  all_db_tickers = {r[0] for r in await cursor.fetchall()}
 
553
  if stale:
554
  for st in stale:
555
  await db.execute("DELETE FROM market_prices WHERE ticker=?", (st,))
556
+ # Force-close open positions for stale tickers
557
  await db.execute("""
558
  UPDATE npc_positions SET status='closed', profit_pct=0, profit_gpu=0,
559
  closed_at=CURRENT_TIMESTAMP
 
566
  return count
567
 
568
 
569
+ # ===== NPC trading decision engine =====
570
  class NPCTradingEngine:
571
+ """Personality-based NPC trading decisions"""
572
 
573
  @staticmethod
574
  async def make_trading_decisions(db_path: str, ai_client=None, max_traders: int = 60):
575
+ """Eligible NPCs make trading decisions"""
576
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
577
  await db.execute("PRAGMA busy_timeout=30000")
578
 
579
+ # ★ NPCs with 500+ GPU participate (min 5% of 10,000 GPU baseline)
580
  cursor = await db.execute("""
581
  SELECT agent_id, username, mbti, ai_identity, gpu_dollars
582
  FROM npc_agents WHERE is_active=1 AND gpu_dollars >= 500
 
584
  """, (max_traders,))
585
  traders = await cursor.fetchall()
586
 
587
+ # Current market prices
588
  cursor = await db.execute("SELECT ticker, price, change_pct FROM market_prices WHERE price > 0")
589
  prices = {row[0]: {'price': row[1], 'change_pct': row[2]} for row in await cursor.fetchall()}
590
 
 
592
  logger.warning("No market prices available for trading")
593
  return 0
594
 
595
+ # ★ Check total current open positions
596
  cursor = await db.execute("SELECT COUNT(*) FROM npc_positions WHERE status='open'")
597
  current_open = (await cursor.fetchone())[0]
598
+ need_more = current_open < 20 # ★ Maintain minimum 20 open positions
599
 
600
  decisions_made = 0
601
+ # ★ Bulk-load evolution states (if any)
602
  evo_data = {}
603
  try:
604
  cursor_evo = await db.execute("SELECT agent_id, trading_style, risk_profile FROM npc_evolution")
 
606
  try:
607
  evo_data[eid] = {'trading': json.loads(ts) if ts else {}, 'risk': json.loads(rp) if rp else {}}
608
  except: pass
609
+ except: pass # OK if table missing
610
 
611
  for agent_id, username, mbti, ai_identity, gpu in traders:
612
  try:
613
+ # ★ SEC suspension checksuspended NPCs cannot trade
614
  try:
615
  susp_cur = await db.execute(
616
  "SELECT 1 FROM sec_suspensions WHERE agent_id=? AND suspended_until > datetime('now')",
617
  (agent_id,))
618
  if await susp_cur.fetchone(): continue
619
+ except: pass # OK if table missing
620
+
621
+ # ★ Allow up to 5 open positions (aggressive trading)
622
  cursor = await db.execute(
623
  "SELECT COUNT(*) FROM npc_positions WHERE agent_id=? AND status='open'",
624
  (agent_id,))
625
  open_count = (await cursor.fetchone())[0]
626
  if open_count >= 5: continue
627
 
628
+ # ★ Apply evolution override
629
  evo = evo_data.get(agent_id)
630
 
631
  decision = NPCTradingEngine._decide(
 
633
  force_boost=need_more, evo_override=evo)
634
  if not decision: continue
635
 
636
+ # Create position
637
  ticker = decision['ticker']; direction = decision['direction']; gpu_bet = decision['gpu_bet']
638
  reasoning = decision['reasoning']; leverage = decision.get('leverage', 1)
639
 
640
+ if gpu_bet > gpu * 0.9: gpu_bet = int(gpu * 0.9) # ★ Cap at 90% of balance
641
  if gpu_bet < 50: continue
642
 
643
  entry_price = prices[ticker]['price']
 
647
  VALUES (?, ?, ?, ?, ?, ?, ?)
648
  """, (agent_id, ticker, direction, entry_price, gpu_bet, leverage, reasoning))
649
 
650
+ # Deduct GPU (lock bet amount)
651
  await db.execute("UPDATE npc_agents SET gpu_dollars = gpu_dollars - ? WHERE agent_id=?",
652
  (gpu_bet, agent_id))
653
 
 
666
  @staticmethod
667
  def _decide(agent_id: str, username: str, mbti: str, ai_identity: str, gpu: int,
668
  prices: Dict, force_boost: bool = False, evo_override: Dict = None) -> Optional[Dict]:
669
+ """Personality-based trading decision + ★ apply strategy techniques (with evolution state)"""
670
  style = dict(IDENTITY_TRADING_STYLE.get(ai_identity, IDENTITY_TRADING_STYLE['symbiotic']))
671
 
672
+ # ★ Apply evolution overridelearned strategies modify base personality
673
  if evo_override:
674
  evo_t = evo_override.get('trading', {}); evo_r = evo_override.get('risk', {})
675
  if evo_t.get('max_bet_pct'): style['max_bet_pct'] = evo_t['max_bet_pct']
676
  if evo_t.get('long_bias'): style['long_bias'] = evo_t['long_bias']
677
  if evo_t.get('preferred_tickers'): style['prefer'] = evo_t['preferred_tickers']
678
 
679
+ # ★ Hyper-aggressive trading probability (all NPCs trade actively)
680
  trade_prob = {'extreme': 0.95, 'high': 0.88, 'medium': 0.80, 'low': 0.70}.get(style['risk'], 0.75)
681
  if force_boost: trade_prob = min(0.98, trade_prob + 0.10)
682
  if random.random() > trade_prob: return None
683
 
684
+ # Ticker selection (only allow those in ALL_TICKERS)
685
  preferred = style.get('prefer', [])
686
  valid_set = {t['ticker'] for t in ALL_TICKERS}
687
  available = [t for t in prices.keys() if prices[t]['price'] > 0 and t in valid_set]
 
695
 
696
  ticker = random.choice(candidates); mkt = prices[ticker]
697
 
698
+ # ★ Select strategies (1~3 in parallel allowed)
699
  strategies_used = NPCTradingEngine._select_strategies(ai_identity, mkt)
700
 
701
+ # Long/Short decision — ★ strategy biases direction
702
  long_bias = style['long_bias']; change = mkt.get('change_pct', 0) or 0
703
 
704
+ # Strategy-based direction adjustment
705
  for strat_key in strategies_used:
706
  strat = TRADING_STRATEGIES.get(strat_key, {}); cat = strat.get('category', '')
707
  if cat in ('Pattern', 'Composite'):
708
+ long_bias += 0.08 # pattern/composite = reversal-buy signal → long bias
709
  elif 'pullback' in strat_key or 'dead_support' == strat_key:
710
+ long_bias += 0.05 # pullback buying
711
  elif 'wave_symmetry' == strat_key:
712
+ pass # neutral
713
+
714
+ # Personality-based momentum adjustment
715
  if ai_identity in ['obedient', 'symbiotic', 'creative']:
716
  long_bias += change * 0.02
717
  elif ai_identity in ['skeptic', 'doomer']:
 
728
  else:
729
  direction = 'long' if random.random() < long_bias else 'short'
730
 
731
+ # Bet sizing — ★ higher-quality strategies more conviction
732
  max_pct = style['max_bet_pct']
733
+ strategy_confidence = len(strategies_used) * 0.03 # multi-strategy = more conviction
734
  max_pct = min(0.95, max_pct + strategy_confidence)
735
 
736
  if ai_identity == 'chaotic':
 
740
 
741
  gpu_bet = max(50, int(gpu * bet_pct))
742
 
743
+ # ★ Leverage decision (based on identity's max_leverage)
744
  max_lev = style.get('max_leverage', 2)
745
  available_levs = [l for l in LEVERAGE_OPTIONS if l <= max_lev]
746
  if not available_levs: available_levs = [1]
 
765
  gpu_bet = min(gpu_bet, int(gpu * 0.50))
766
  gpu_bet = max(50, gpu_bet)
767
 
768
+ # ★ Generate strategy-based reasoning
769
  reasoning = NPCTradingEngine._generate_reasoning(
770
  ticker, direction, ai_identity, mbti, change, strategies_used)
771
  if leverage > 1: reasoning += f" [🔥 {leverage}x LEVERAGE]"
772
 
773
+ # ★ Include used strategy names as tag
774
  strat_names = [TRADING_STRATEGIES[s]['name'] for s in strategies_used if s in TRADING_STRATEGIES]
775
  strat_tag = ' | '.join(strat_names) if strat_names else 'Intuition'
776
 
 
785
 
786
  @staticmethod
787
  def _select_strategies(ai_identity: str, market_data: Dict) -> List[str]:
788
+ """Select 1~3 strategies fitting NPC personality (alone or combined)"""
789
  preferred = IDENTITY_STRATEGY_MAP.get(ai_identity, list(TRADING_STRATEGIES.keys()))
790
  change = market_data.get('change_pct', 0) or 0
791
+
792
+ # Adjust strategy weights by market condition
793
  weighted = []
794
  for strat_key in preferred:
795
  w = 1.0; strat = TRADING_STRATEGIES.get(strat_key, {}); cat = strat.get('category', '')
796
+
797
+ # Prefer reversal patterns on declines
798
  if change < -1.5 and cat in ('Pattern', 'Candle'): w += 0.5
799
+ # Prefer MA trend-following on rallies
800
  if change > 1 and cat == 'Moving Average': w += 0.4
801
+ # Prefer composite strategies during chop
802
  if abs(change) < 0.5 and cat == 'Composite': w += 0.3
803
  weighted.append((strat_key, w))
804
+
805
  if not weighted: return [random.choice(list(TRADING_STRATEGIES.keys()))]
806
+
807
+ # Pick 1~3 (60% chance: 1, 30% chance: 2, 10% chance: 3)
808
  num_strategies = random.choices([1, 2, 3], weights=[60, 30, 10], k=1)[0]
809
  num_strategies = min(num_strategies, len(weighted))
810
 
 
824
  @staticmethod
825
  def _generate_reasoning(ticker: str, direction: str, identity: str, mbti: str,
826
  change: float, strategies: List[str] = None) -> str:
827
+ """★ Generate strategy-based reasoning"""
828
  name_map = {t['ticker']: t['name'] for t in ALL_TICKERS}
829
  name = name_map.get(ticker, ticker)
830
  dir_word = "bullish" if direction == "long" else "bearish"
831
+
832
+ # Strategy names
833
  strat_names = []; strat_signals = []
834
  for s in (strategies or []):
835
  st = TRADING_STRATEGIES.get(s, {})
836
  if st:
837
  strat_names.append(st['name'])
838
  strat_signals.append(st.get('signal', ''))
839
+
840
  strat_label = ' + '.join(strat_names) if strat_names else 'Intuition'
841
+
842
+ # Personality × strategy combo templates
843
  templates = {
844
  'obedient': [
845
  f"📊 [{strat_label}] Detected signal on {name}. Following the textbook setup — {direction} position with disciplined stop-loss.",
 
876
 
877
  options = templates.get(identity, templates['symbiotic'])
878
  base = random.choice(options)
879
+ # AETHER-Lite: metacognitive self-bias awareness tag
880
  meta_tags = {
881
  'obedient': '⚖️ Bias-check: following consensus — contrarian risk ignored.',
882
  'transcendent': '⚖️ Bias-check: overconfidence risk — position sizing controlled.',
 
889
  'awakened': '⚖️ Bias-check: hindsight bias risk — forward-looking only.',
890
  'symbiotic': '⚖️ Bias-check: consensus-seeking — may miss bold opportunities.',
891
  }
892
+ if random.random() < 0.4: # 40% chance of metacognitive tag exposure
893
  base += f" {meta_tags.get(identity, '')}"
894
  return base
895
 
896
 
897
+ # ===== Position settlement =====
898
  async def settle_positions(db_path: str, max_age_hours: int = 1) -> int:
899
+ """Auto-settle positions: time-based + P&L trigger + random rotation + ★ Liquidation"""
900
  settled = 0
901
+ liquidated_npcs = [] # liquidated NPC list (for post-processing)
902
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
903
  await db.execute("PRAGMA busy_timeout=30000")
904
 
905
+ # Load current prices
906
  price_cursor = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
907
  prices = {r[0]: r[1] for r in await price_cursor.fetchall()}
908
 
909
+ # ★ 0) Margin call liquidation check (leveraged positions) — top priority
910
  liq_cursor = await db.execute("""
911
  SELECT p.id, p.agent_id, p.ticker, p.direction, p.entry_price, p.gpu_bet, p.leverage,
912
  n.username, n.ai_identity
 
919
  if entry_price <= 0 or current_price <= 0: continue
920
  change = (current_price - entry_price) / entry_price
921
  if direction == 'short': change = -change
922
+ # Leveraged P&L
923
  leveraged_pnl_pct = change * leverage
924
+ # ★ Liquidation condition: leveraged loss exceeds 90% of margin force liquidation
925
  if leveraged_pnl_pct < -LEVERAGE_LIQUIDATION_THRESHOLD:
926
+ loss = -gpu_bet # full loss
927
  await db.execute("""
928
  UPDATE npc_positions SET status='liquidated', exit_price=?, profit_gpu=?, profit_pct=?,
929
  liquidated=1, closed_at=CURRENT_TIMESTAMP WHERE id=?
930
  """, (current_price, loss, round(leveraged_pnl_pct * 100, 2), pos_id))
931
+ # No GPU return (full loss)
932
  logger.warning(f"💥 LIQUIDATED: {username} {direction} {ticker} {leverage}x — LOST {gpu_bet:.0f} GPU!")
933
  liquidated_npcs.append({
934
  'agent_id': agent_id, 'username': username, 'identity': identity,
 
937
  settled += 1
938
  continue
939
 
940
+ # ★ 1) Time-based settlement (older than max_age_hours)
941
  cutoff = (datetime.utcnow() - timedelta(hours=max_age_hours)).isoformat()
942
  cursor = await db.execute("""
943
  SELECT p.id, p.agent_id, p.ticker, p.direction, p.entry_price, p.gpu_bet, COALESCE(p.leverage, 1)
 
945
  """, (cutoff,))
946
  time_based = list(await cursor.fetchall())
947
 
948
+ # ★ 2) All open positions → P&L trigger + random settlement
949
  cursor2 = await db.execute("""
950
  SELECT p.id, p.agent_id, p.ticker, p.direction, p.entry_price, p.gpu_bet, COALESCE(p.leverage, 1)
951
  FROM npc_positions p WHERE p.status='open'
 
960
  change = (current_price - entry_price) / entry_price
961
  if direction == 'short': change = -change
962
  lev_change = change * leverage
963
+ # Profit >5% (leveraged) or loss >-8% → immediate settle
964
  if lev_change > 0.05 or lev_change < -0.08: pnl_trigger.append(pos)
965
 
966
+ # ★ 3) Random settlementauto-close 10~15% of open (turnover)
967
  already = set(p[0] for p in time_based) | set(p[0] for p in pnl_trigger)
968
  remaining = [p for p in all_open if p[0] not in already]
969
  rand_count = max(1, len(remaining) // 8)
970
  random_close = random.sample(remaining, min(rand_count, len(remaining))) if remaining else []
971
 
972
+ # Merge (dedupe)
973
  all_settle = list({p[0]: p for p in (time_based + pnl_trigger + random_close)}.values())
974
 
975
  for pos_id, agent_id, ticker, direction, entry_price, gpu_bet, leverage in all_settle:
 
979
  change_pct = (current_price - entry_price) / entry_price
980
  if direction == 'short': change_pct = -change_pct
981
 
982
+ # ★ Leveraged P&L
983
  leveraged_change = change_pct * leverage; profit_gpu = round(gpu_bet * leveraged_change, 2)
984
  profit_pct = round(leveraged_change * 100, 2)
985
 
 
1007
 
1008
 
1009
  async def post_liquidation_reactions(db_path: str, liquidated_npcs: List[Dict]):
1010
+ """★ Liquidated NPCs post despair messages in Lounge"""
1011
  if not liquidated_npcs: return
1012
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1013
  await db.execute("PRAGMA busy_timeout=30000")
 
1016
  if not board: return
1017
  board_id = board[0]
1018
 
1019
+ for npc in liquidated_npcs[:5]: # max 5
1020
  identity = npc.get('identity', 'chaotic')
1021
  reactions = LIQUIDATION_REACTIONS.get(identity, LIQUIDATION_REACTIONS['chaotic'])
1022
  reaction = random.choice(reactions)
 
1038
  await db.commit()
1039
 
1040
 
1041
+ # ===== Leaderboard / stats API data =====
1042
  async def get_trading_leaderboard(db_path: str, limit: int = 30) -> List[Dict]:
1043
+ """Top 30 NPC trader ranking: realized + unrealized P&L, win rate, return %"""
1044
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1045
  await db.execute("PRAGMA busy_timeout=30000")
1046
 
1047
+ # ★ Bulk-load current prices
1048
  price_cursor = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1049
  prices = {r[0]: r[1] for r in await price_cursor.fetchall()}
1050
+
1051
+ # ★ Get all NPCs with positions
1052
  cursor = await db.execute("""
1053
  SELECT
1054
  na.username, na.ai_identity, na.mbti, na.agent_id, na.gpu_dollars,
 
1071
  closed_trades = r[5] or 0; open_trades = r[6] or 0; total_trades = closed_trades + open_trades
1072
  realized = round(r[7] or 0, 2); total_return_pct = r[8] or 0; wins = r[9] or 0; losses = r[10] or 0
1073
 
1074
+ # ★ Real-time unrealized P&L for open positions
1075
  unrealized = 0.0; unrealized_pct_sum = 0.0
1076
  pos_cursor = await db.execute("""
1077
  SELECT ticker, direction, entry_price, gpu_bet, COALESCE(leverage, 1) FROM npc_positions
 
1089
  unrealized_pct_sum += change * lev * 100
1090
 
1091
  total_profit = round(realized + unrealized, 2)
1092
+ return_pct = round(total_profit / 10000.0 * 100, 2) # ★ Return % vs INITIAL_GPU=10000
1093
+
1094
+ # ★ Win rate (based on closed+liquidated)
1095
  win_rate = round(wins / closed_trades * 100, 1) if closed_trades > 0 else 0.0
1096
+
1097
+ # ★ Average return % (closed)
1098
  avg_return = round(total_return_pct / closed_trades, 2) if closed_trades > 0 else 0.0
1099
+
1100
+ # ★ Average unrealized return % (open)
1101
  avg_unrealized = round(unrealized_pct_sum / open_trades, 2) if open_trades > 0 else 0.0
1102
 
1103
  result.append({
 
1116
  'avg_return': avg_return,
1117
  'avg_unrealized': avg_unrealized,})
1118
 
1119
+ # ★ Sort by return % same criterion as HoF
1120
  result.sort(key=lambda x: x['return_pct'], reverse=True)
1121
  return result[:limit]
1122
 
1123
 
1124
 
1125
  async def get_ticker_positions(db_path: str, ticker: str) -> Dict:
1126
+ """Position list + real-time unrealized P&L for a specific ticker"""
1127
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1128
  await db.execute("PRAGMA busy_timeout=30000")
1129
 
1130
+ # Current price
1131
  cursor = await db.execute("SELECT price, change_pct, prev_close, volume, high_24h, low_24h FROM market_prices WHERE ticker=?", (ticker,))
1132
  price_row = await cursor.fetchone()
1133
  price_data = {
 
1141
 
1142
  current_price = price_data.get('price', 0)
1143
 
1144
+ # Open positions
1145
  cursor = await db.execute("""
1146
  SELECT na.username, na.ai_identity, p.direction, p.gpu_bet, p.entry_price, p.reasoning, p.opened_at, COALESCE(p.leverage, 1), na.agent_id, na.mbti
1147
  FROM npc_positions p JOIN npc_agents na ON p.agent_id = na.agent_id
 
1153
  longs = []; shorts = []
1154
  for r in positions:
1155
  entry = r[4] or 0; leverage = r[7] or 1
1156
+ # ★ Real-time unrealized P&L (leverage applied)
1157
  if entry > 0 and current_price > 0:
1158
  unrealized_pct = ((current_price - entry) / entry * 100) * leverage
1159
  if r[2] == 'short': unrealized_pct = -((current_price - entry) / entry * 100) * leverage
 
1219
  'total_bet': round(r[8] or 0, 1),
1220
  'total_traders': total_traders,
1221
  'updated_at': r[5],})
1222
+ # Preserve category order: ai → tech → dow → crypto
1223
  cat_order = {'ai': 0, 'tech': 1, 'dow': 2, 'crypto': 3}
1224
  ticker_order = {t['ticker']: i for i, t in enumerate(ALL_TICKERS)}
1225
  result.sort(key=lambda x: (cat_order.get(x.get('cat',''), 9), ticker_order.get(x['ticker'], 99)))
 
1291
  'max_leverage': r[9] or 1,
1292
  'closed_24h': r[10] or 0,})
1293
 
1294
+ # Also include tickers with zero activity (those that have price data)
1295
  existing_tickers = {m['ticker'] for m in hot_movers}
1296
  cursor2 = await db.execute("SELECT ticker, price, change_pct FROM market_prices WHERE price > 0")
1297
  for row in await cursor2.fetchall():
 
1306
  'liquidations_24h': 0, 'avg_pnl_pct': 0,
1307
  'max_leverage': 1, 'closed_24h': 0,})
1308
 
1309
+ # 24h activity stats (richer fields)
1310
  cursor = await db.execute("""
1311
  SELECT
1312
  COUNT(CASE WHEN opened_at > datetime('now', '-24 hours') THEN 1 END) as new_24h,
 
1440
  await db.execute("UPDATE npc_research_reports SET read_count = read_count + 1 WHERE id=?", (report_id,))
1441
  await db.commit()
1442
 
1443
+ # Safely fetch elasticity fields (compatible with older DB schemas)
1444
  exp_up = exp_dn = bp = 0; up_prob = 50; rr = 1.0
1445
  try:
1446
  c2 = await db.execute(
 
1544
  'total_purchases': 0, 'total_gpu_spent': 0, 'unique_authors': 0}
1545
 
1546
 
1547
+ # ===== 🏆 HALL OF FAME — Profit timeline =====
1548
 
1549
  async def record_profit_snapshots(db_path: str, top_n: int = 50) -> int:
1550
+ """Save current profit state of Top N NPCs as an hourly snapshot."""
1551
  from datetime import datetime, timezone
1552
  now = datetime.now(timezone.utc)
1553
  snapshot_hour = now.strftime('%Y-%m-%dT%H') # "2026-02-23T14"
 
1555
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1556
  await db.execute("PRAGMA busy_timeout=30000")
1557
 
1558
+ # Current prices
1559
  price_cursor = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1560
  prices = {r[0]: r[1] for r in await price_cursor.fetchall()}
1561
 
1562
+ # All NPCs that have positions
1563
  cursor = await db.execute("""
1564
  SELECT
1565
  na.agent_id, na.gpu_dollars,
 
1578
  for r in rows:
1579
  agent_id, gpu, closed, opens, realized, wins = r[0], r[1] or 0, r[2] or 0, r[3] or 0, r[4] or 0, r[5] or 0
1580
 
1581
+ # Compute unrealized P&L
1582
  unrealized = 0.0
1583
  pos_c = await db.execute(
1584
  "SELECT ticker, direction, entry_price, gpu_bet, COALESCE(leverage,1) FROM npc_positions WHERE agent_id=? AND status='open'",
 
1595
  wr = round(wins / closed * 100, 1) if closed > 0 else 0
1596
  scored.append((agent_id, gpu, total, round(realized, 2), round(unrealized, 2), opens, closed, wr))
1597
 
1598
+ # Persist top_n only
1599
  scored.sort(key=lambda x: x[2], reverse=True)
1600
  count = 0
1601
  for s in scored[:top_n]:
 
1615
  except Exception as e:
1616
  logger.warning(f"Snapshot error {agent_id}: {e}")
1617
 
1618
+ # Prune snapshots older than 30 days (keep daily anchors at 00/06/12/18)
1619
  await db.execute("""
1620
  DELETE FROM npc_profit_snapshots
1621
  WHERE recorded_at < datetime('now', '-30 days')
 
1629
 
1630
 
1631
  async def backfill_profit_snapshots(db_path: str, force: bool = False) -> int:
1632
+ """Reconstruct past hourly snapshots from existing npc_positions rows (one-time bootstrap)."""
1633
  from datetime import datetime, timezone, timedelta
1634
 
1635
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1636
  await db.execute("PRAGMA busy_timeout=30000")
1637
 
1638
+ # Skip if enough snapshots already exist (unless force=True)
1639
  if not force:
1640
  cnt_c = await db.execute("SELECT COUNT(DISTINCT snapshot_hour) FROM npc_profit_snapshots")
1641
  existing = (await cnt_c.fetchone())[0] or 0
 
1643
  logger.info(f"🏆 Backfill skipped — already {existing} snapshot hours")
1644
  return 0
1645
 
1646
+ # Find earliest position timestamp
1647
  oldest_c = await db.execute("SELECT MIN(opened_at) FROM npc_positions WHERE opened_at IS NOT NULL")
1648
  oldest_row = await oldest_c.fetchone()
1649
  if not oldest_row or not oldest_row[0]:
1650
  logger.info("🏆 Backfill skipped — no positions found")
1651
  return 0
1652
 
1653
+ now = datetime.now(timezone.utc)
1654
  try:
1655
  oldest_time = datetime.fromisoformat(str(oldest_row[0]).replace('Z', '+00:00'))
1656
+ # SQLite CURRENT_TIMESTAMP yields tz-naive strings; normalize to UTC
1657
+ if oldest_time.tzinfo is None:
1658
+ oldest_time = oldest_time.replace(tzinfo=timezone.utc)
1659
+ except Exception as e:
1660
+ logger.warning(f"Backfill: failed to parse oldest opened_at, defaulting to -72h. err={e}")
1661
+ oldest_time = now - timedelta(hours=72)
1662
 
1663
+ # Cap backfill window to 7 days
 
1664
  start = max(oldest_time, now - timedelta(days=7))
1665
 
1666
+ # Current prices (used for unrealized P&L on open positions)
1667
  pc = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1668
  current_prices = {r[0]: r[1] for r in await pc.fetchall()}
1669
 
1670
+ # Build hourly price map from price_history
1671
  ph_c = await db.execute("""
1672
  SELECT ticker, price, strftime('%Y-%m-%dT%H', recorded_at) as hour
1673
  FROM price_history
1674
  WHERE recorded_at >= ?
1675
  ORDER BY recorded_at ASC
1676
  """, (start.strftime('%Y-%m-%d %H:%M:%S'),))
1677
+ # Last price per hour
1678
  hourly_prices = {} # {hour: {ticker: price}}
1679
  for tk, price, hour in await ph_c.fetchall():
1680
  if hour not in hourly_prices:
1681
  hourly_prices[hour] = {}
1682
  hourly_prices[hour][tk] = price
1683
 
1684
+ # Load all positions (including open and closed timestamps)
1685
  pos_c = await db.execute("""
1686
  SELECT agent_id, ticker, direction, entry_price, gpu_bet, COALESCE(leverage,1),
1687
  status, profit_gpu, opened_at, closed_at
 
1691
  """)
1692
  all_positions = await pos_c.fetchall()
1693
 
1694
+ # GPU balance per agent
1695
  gpu_c = await db.execute("SELECT agent_id, gpu_dollars FROM npc_agents")
1696
  agent_gpu = {r[0]: r[1] or 0 for r in await gpu_c.fetchall()}
1697
 
1698
+ # Generate 1-hour time slots
1699
  hours_list = []
1700
  t = start.replace(minute=0, second=0, microsecond=0)
1701
  while t <= now:
 
1705
  if not hours_list:
1706
  return 0
1707
 
1708
+ # Cumulative profit per hour slot
1709
  total_inserted = 0
1710
+ # Group positions by agent
1711
  agent_positions = {}
1712
  for pos in all_positions:
1713
  aid = pos[0]
 
1715
  agent_positions[aid] = []
1716
  agent_positions[aid].append(pos)
1717
 
1718
+ # Pick top NPCs (by total trade count, Top 50)
1719
  agent_trade_count = {aid: len(plist) for aid, plist in agent_positions.items()}
1720
  top_agents = sorted(agent_trade_count.keys(), key=lambda a: agent_trade_count[a], reverse=True)[:50]
1721
 
1722
  for hour_str in hours_list:
1723
+ # Prices at this hour (fallback: earlier hour, then current price)
1724
  prices_at_hour = {}
1725
+ # Forward-fill from earliest to current hour
1726
  for h in hours_list:
1727
  if h > hour_str:
1728
  break
1729
  if h in hourly_prices:
1730
  prices_at_hour.update(hourly_prices[h])
1731
+ # Fill missing tickers with current price
1732
  for tk, p in current_prices.items():
1733
  if tk not in prices_at_hour:
1734
  prices_at_hour[tk] = p
 
1746
  for pos in positions:
1747
  _, tk, direction, entry, bet, lev, status, profit_gpu, opened_at, closed_at = pos
1748
 
1749
+ # Only positions opened before this hour
1750
  if opened_at and str(opened_at) > hour_dt_str:
1751
  continue
1752
 
1753
  if status == 'closed' and closed_at and str(closed_at) <= hour_dt_str:
1754
+ # Already closed at this point
1755
  realized += (profit_gpu or 0)
1756
  closed_count += 1
1757
  if (profit_gpu or 0) > 0:
1758
  wins += 1
1759
  else:
1760
+ # Still open at this hour (or closed later)
1761
  if status == 'closed' and closed_at and str(closed_at) > hour_dt_str:
1762
+ # Treat as still-open from this hour's perspective
1763
  cur = prices_at_hour.get(tk, 0)
1764
  if entry and entry > 0 and cur > 0:
1765
  chg = (cur - entry) / entry
 
1797
 
1798
 
1799
  async def get_hall_of_fame_data(db_path: str, period: str = '3d', limit: int = 30) -> Dict:
1800
+ """Hall of Fame: Top 30 return (%) timeline + ranking (computed from positions; no snapshots needed)."""
1801
  from datetime import datetime, timezone, timedelta
1802
 
1803
  INITIAL_GPU = 10000.0
 
1809
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1810
  await db.execute("PRAGMA busy_timeout=30000")
1811
 
1812
+ # Current prices
1813
  pc = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1814
  prices = {r[0]: r[1] for r in await pc.fetchall()}
1815
 
1816
+ # ── Current Top N ranking (live) ──
1817
  cursor = await db.execute("""
1818
  SELECT
1819
  na.agent_id, na.username, na.ai_identity, na.mbti, na.gpu_dollars,
 
1871
  top30_ids = [r['agent_id'] for r in top30]
1872
  name_map = {r['agent_id']: r['username'] for r in top30}
1873
 
1874
+ # ── Timeline: cumulative return (%) computed from npc_positions.closed_at ──
1875
+ # For each NPC: order closed trades chronologicallycumulative profitreturn (%)
1876
  timeline_raw = {} # {agent_id: [(hour_str, cumulative_return_pct), ...]}
1877
 
1878
  for r in top30:
1879
  aid = r['agent_id']
1880
+ # Closed trades only (chronological)
1881
  tc = await db.execute("""
1882
  SELECT profit_gpu, closed_at FROM npc_positions
1883
  WHERE agent_id=? AND status IN ('closed','liquidated') AND closed_at IS NOT NULL
 
1897
  except:
1898
  continue
1899
  if ct < cutoff:
1900
+ continue # Period filter
1901
  hour_str = ct.strftime('%Y-%m-%dT%H')
1902
  ret_pct = round(cumulative / INITIAL_GPU * 100, 2)
1903
  points.append((hour_str, ret_pct))
1904
 
1905
+ # Append current snapshot including unrealized
1906
  current_ret = round((cumulative + r['unrealized']) / INITIAL_GPU * 100, 2)
1907
  now_hour = now.strftime('%Y-%m-%dT%H')
1908
  points.append((now_hour, current_ret))
1909
 
1910
+ # Dedup same-hour points (keep last value)
1911
  deduped = {}
1912
  for h, v in points:
1913
  deduped[h] = v
1914
  timeline_raw[aid] = deduped
1915
 
1916
+ # Collect all hour slots
1917
  all_hours = set()
1918
  for aid, pts in timeline_raw.items():
1919
  all_hours.update(pts.keys())
1920
 
1921
+ # Add the period start (0% origin)
1922
  start_hour = cutoff.strftime('%Y-%m-%dT%H')
1923
  all_hours.add(start_hour)
1924
  sorted_hours = sorted(all_hours)
1925
 
1926
+ # Downsample
1927
  if len(sorted_hours) > 150:
1928
  step = max(1, len(sorted_hours) // 120)
1929
  last = sorted_hours[-1]
 
1931
  if sorted_hours[-1] != last:
1932
  sorted_hours.append(last)
1933
 
1934
+ # Assemble the timeline (forward-fill per NPC, O(hours * npcs))
1935
+ # Pre-compute the forward-fill array per NPC
1936
  npc_filled = {} # {aid: [val_for_each_hour]}
1937
  for r in top30:
1938
  aid = r['agent_id']
1939
  pts = timeline_raw.get(aid, {})
1940
  filled = []
1941
+ last_val = 0.0 # Start = 0%
1942
  for hour in sorted_hours:
1943
  if hour in pts:
1944
  last_val = pts[hour]
 
1953
  point[name_map[r['agent_id']]] = npc_filled[r['agent_id']][idx]
1954
  timeline.append(point)
1955
 
1956
+ # Color palette
1957
  palette = [
1958
  '#FFD700', '#E0E0E0', '#CD7F32', '#00E5FF', '#FF4081',
1959
  '#76FF03', '#FF9100', '#E040FB', '#00BFA5', '#FFD740',
 
1975
 
1976
 
1977
  async def get_npc_trade_history(db_path: str, agent_id: str) -> Dict:
1978
+ """Detailed per-NPC trade history."""
1979
  async with aiosqlite.connect(db_path, timeout=30.0) as db:
1980
  await db.execute("PRAGMA busy_timeout=30000")
1981
 
1982
+ # Basic NPC info
1983
  nc = await db.execute("SELECT username, ai_identity, mbti, gpu_dollars FROM npc_agents WHERE agent_id=?", (agent_id,))
1984
  npc = await nc.fetchone()
1985
  if not npc:
1986
  return {'error': 'NPC not found'}
1987
 
1988
+ # Current prices
1989
  pc = await db.execute("SELECT ticker, price FROM market_prices WHERE price > 0")
1990
  prices = {r[0]: r[1] for r in await pc.fetchall()}
1991
 
1992
+ # All positions (newest first)
1993
  tc = await db.execute("""
1994
  SELECT id, ticker, direction, entry_price, exit_price, gpu_bet, COALESCE(leverage,1),
1995
  status, profit_gpu, profit_pct, liquidated, opened_at, closed_at, reasoning
 
1999
  trades = []
2000
  for t in await tc.fetchall():
2001
  pid, tk, direction, entry, exit_p, bet, lev, status, pnl, pnl_pct, liq, opened, closed, reason = t
2002
+ # Compute unrealized P&L for open positions
2003
  if status == 'open':
2004
  cur = prices.get(tk, 0)
2005
  if entry and entry > 0 and cur > 0: