Compare commits
4 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fdb7a29d4 | ||
| 31b4d0bf1e | |||
|
|
e2275a23b1 | ||
| 7522bb7e66 |
@@ -101,4 +101,7 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def enabled_market_list(self) -> list[str]:
|
def enabled_market_list(self) -> list[str]:
|
||||||
"""Parse ENABLED_MARKETS into list of market codes."""
|
"""Parse ENABLED_MARKETS into list of market codes."""
|
||||||
return [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]
|
from src.markets.schedule import expand_market_codes
|
||||||
|
|
||||||
|
raw = [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]
|
||||||
|
return expand_market_codes(raw)
|
||||||
|
|||||||
@@ -26,7 +26,19 @@ def create_dashboard_app(db_path: str) -> FastAPI:
|
|||||||
def get_status() -> dict[str, Any]:
|
def get_status() -> dict[str, Any]:
|
||||||
today = datetime.now(UTC).date().isoformat()
|
today = datetime.now(UTC).date().isoformat()
|
||||||
with _connect(db_path) as conn:
|
with _connect(db_path) as conn:
|
||||||
markets = ["KR", "US"]
|
market_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT market FROM (
|
||||||
|
SELECT market FROM trades WHERE DATE(timestamp) = ?
|
||||||
|
UNION
|
||||||
|
SELECT market FROM decision_logs WHERE DATE(timestamp) = ?
|
||||||
|
UNION
|
||||||
|
SELECT market FROM playbooks WHERE date = ?
|
||||||
|
) ORDER BY market
|
||||||
|
""",
|
||||||
|
(today, today, today),
|
||||||
|
).fetchall()
|
||||||
|
markets = [row[0] for row in market_rows] if market_rows else []
|
||||||
market_status: dict[str, Any] = {}
|
market_status: dict[str, Any] = {}
|
||||||
total_trades = 0
|
total_trades = 0
|
||||||
total_pnl = 0.0
|
total_pnl = 0.0
|
||||||
|
|||||||
21
src/db.py
21
src/db.py
@@ -214,3 +214,24 @@ def get_latest_buy_trade(
|
|||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return {"decision_id": row[0], "price": row[1], "quantity": row[2]}
|
return {"decision_id": row[0], "price": row[1], "quantity": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_position(
|
||||||
|
conn: sqlite3.Connection, stock_code: str, market: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return open position if latest trade is BUY, else None."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT action, decision_id, price, quantity
|
||||||
|
FROM trades
|
||||||
|
WHERE stock_code = ?
|
||||||
|
AND market = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(stock_code, market),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or row[0] != "BUY":
|
||||||
|
return None
|
||||||
|
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
|
||||||
|
|||||||
201
src/main.py
201
src/main.py
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
@@ -28,7 +29,7 @@ from src.context.store import ContextStore
|
|||||||
from src.core.criticality import CriticalityAssessor
|
from src.core.criticality import CriticalityAssessor
|
||||||
from src.core.priority_queue import PriorityTaskQueue
|
from src.core.priority_queue import PriorityTaskQueue
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
||||||
from src.db import get_latest_buy_trade, init_db, log_trade
|
from src.db import get_latest_buy_trade, get_open_position, init_db, log_trade
|
||||||
from src.evolution.daily_review import DailyReviewer
|
from src.evolution.daily_review import DailyReviewer
|
||||||
from src.evolution.optimizer import EvolutionOptimizer
|
from src.evolution.optimizer import EvolutionOptimizer
|
||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
@@ -114,6 +115,7 @@ async def trading_cycle(
|
|||||||
|
|
||||||
current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0"))
|
current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0"))
|
||||||
foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
|
foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
|
||||||
|
price_change_pct = safe_float(orderbook.get("output1", {}).get("prdy_ctrt", "0"))
|
||||||
else:
|
else:
|
||||||
# Overseas market
|
# Overseas market
|
||||||
price_data = await overseas_broker.get_overseas_price(
|
price_data = await overseas_broker.get_overseas_price(
|
||||||
@@ -136,6 +138,7 @@ async def trading_cycle(
|
|||||||
|
|
||||||
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
|
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
|
||||||
foreigner_net = 0.0 # Not available for overseas
|
foreigner_net = 0.0 # Not available for overseas
|
||||||
|
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
|
||||||
|
|
||||||
# Calculate daily P&L %
|
# Calculate daily P&L %
|
||||||
pnl_pct = (
|
pnl_pct = (
|
||||||
@@ -149,6 +152,7 @@ async def trading_cycle(
|
|||||||
"market_name": market.name,
|
"market_name": market.name,
|
||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
|
"price_change_pct": price_change_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Enrich market_data with scanner metrics for scenario engine
|
# Enrich market_data with scanner metrics for scenario engine
|
||||||
@@ -240,6 +244,34 @@ async def trading_cycle(
|
|||||||
confidence=match.confidence,
|
confidence=match.confidence,
|
||||||
rationale=match.rationale,
|
rationale=match.rationale,
|
||||||
)
|
)
|
||||||
|
stock_playbook = playbook.get_stock_playbook(stock_code)
|
||||||
|
|
||||||
|
if decision.action == "HOLD":
|
||||||
|
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
|
if open_position:
|
||||||
|
entry_price = safe_float(open_position.get("price"), 0.0)
|
||||||
|
if entry_price > 0:
|
||||||
|
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||||
|
stop_loss_threshold = -2.0
|
||||||
|
if stock_playbook and stock_playbook.scenarios:
|
||||||
|
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
||||||
|
|
||||||
|
if loss_pct <= stop_loss_threshold:
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="SELL",
|
||||||
|
confidence=95,
|
||||||
|
rationale=(
|
||||||
|
f"Stop-loss triggered ({loss_pct:.2f}% <= "
|
||||||
|
f"{stop_loss_threshold:.2f}%)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Stop-loss override for %s (%s): %.2f%% <= %.2f%%",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
loss_pct,
|
||||||
|
stop_loss_threshold,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Decision for %s (%s): %s (confidence=%d)",
|
"Decision for %s (%s): %s (confidence=%d)",
|
||||||
stock_code,
|
stock_code,
|
||||||
@@ -278,6 +310,7 @@ async def trading_cycle(
|
|||||||
input_data = {
|
input_data = {
|
||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
|
"price_change_pct": price_change_pct,
|
||||||
"total_eval": total_eval,
|
"total_eval": total_eval,
|
||||||
"total_cash": total_cash,
|
"total_cash": total_cash,
|
||||||
"pnl_pct": pnl_pct,
|
"pnl_pct": pnl_pct,
|
||||||
@@ -507,6 +540,9 @@ async def run_daily_session(
|
|||||||
foreigner_net = safe_float(
|
foreigner_net = safe_float(
|
||||||
orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
|
orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
|
||||||
)
|
)
|
||||||
|
price_change_pct = safe_float(
|
||||||
|
orderbook.get("output1", {}).get("prdy_ctrt", "0")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
price_data = await overseas_broker.get_overseas_price(
|
price_data = await overseas_broker.get_overseas_price(
|
||||||
market.exchange_code, stock_code
|
market.exchange_code, stock_code
|
||||||
@@ -515,12 +551,16 @@ async def run_daily_session(
|
|||||||
price_data.get("output", {}).get("last", "0")
|
price_data.get("output", {}).get("last", "0")
|
||||||
)
|
)
|
||||||
foreigner_net = 0.0
|
foreigner_net = 0.0
|
||||||
|
price_change_pct = safe_float(
|
||||||
|
price_data.get("output", {}).get("rate", "0")
|
||||||
|
)
|
||||||
|
|
||||||
stock_data: dict[str, Any] = {
|
stock_data: dict[str, Any] = {
|
||||||
"stock_code": stock_code,
|
"stock_code": stock_code,
|
||||||
"market_name": market.name,
|
"market_name": market.name,
|
||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
|
"price_change_pct": price_change_pct,
|
||||||
}
|
}
|
||||||
# Enrich with scanner metrics
|
# Enrich with scanner metrics
|
||||||
cand = candidate_map.get(stock_code)
|
cand = candidate_map.get(stock_code)
|
||||||
@@ -820,7 +860,7 @@ async def _run_evolution_loop(
|
|||||||
market_date: str,
|
market_date: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run evolution loop once at US close (end of trading day)."""
|
"""Run evolution loop once at US close (end of trading day)."""
|
||||||
if market_code != "US":
|
if not market_code.startswith("US"):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -936,6 +976,10 @@ async def run(settings: Settings) -> None:
|
|||||||
"/help - Show available commands\n"
|
"/help - Show available commands\n"
|
||||||
"/status - Trading status (mode, markets, P&L)\n"
|
"/status - Trading status (mode, markets, P&L)\n"
|
||||||
"/positions - Current holdings\n"
|
"/positions - Current holdings\n"
|
||||||
|
"/report - Daily summary report\n"
|
||||||
|
"/scenarios - Today's playbook scenarios\n"
|
||||||
|
"/review - Recent scorecards\n"
|
||||||
|
"/dashboard - Dashboard URL/status\n"
|
||||||
"/stop - Pause trading\n"
|
"/stop - Pause trading\n"
|
||||||
"/resume - Resume trading"
|
"/resume - Resume trading"
|
||||||
)
|
)
|
||||||
@@ -1055,11 +1099,164 @@ async def run(settings: Settings) -> None:
|
|||||||
"<b>⚠️ Error</b>\n\nFailed to retrieve positions."
|
"<b>⚠️ Error</b>\n\nFailed to retrieve positions."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def handle_report() -> None:
|
||||||
|
"""Handle /report command - show daily summary metrics."""
|
||||||
|
try:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
trade_row = db_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS trade_count,
|
||||||
|
COALESCE(SUM(pnl), 0.0) AS total_pnl,
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ?
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchone()
|
||||||
|
decision_row = db_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS decision_count,
|
||||||
|
COALESCE(AVG(confidence), 0.0) AS avg_confidence
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE DATE(timestamp) = ?
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
trade_count = int(trade_row[0] if trade_row else 0)
|
||||||
|
total_pnl = float(trade_row[1] if trade_row else 0.0)
|
||||||
|
wins = int(trade_row[2] if trade_row and trade_row[2] is not None else 0)
|
||||||
|
decision_count = int(decision_row[0] if decision_row else 0)
|
||||||
|
avg_confidence = float(decision_row[1] if decision_row else 0.0)
|
||||||
|
win_rate = (wins / trade_count * 100.0) if trade_count > 0 else 0.0
|
||||||
|
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>📈 Daily Report</b>\n\n"
|
||||||
|
f"<b>Date:</b> {today}\n"
|
||||||
|
f"<b>Trades:</b> {trade_count}\n"
|
||||||
|
f"<b>Total P&L:</b> {total_pnl:+.2f}\n"
|
||||||
|
f"<b>Win Rate:</b> {win_rate:.2f}%\n"
|
||||||
|
f"<b>Decisions:</b> {decision_count}\n"
|
||||||
|
f"<b>Avg Confidence:</b> {avg_confidence:.2f}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in /report handler: %s", exc)
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>⚠️ Error</b>\n\nFailed to generate daily report."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_scenarios() -> None:
|
||||||
|
"""Handle /scenarios command - show today's playbook scenarios."""
|
||||||
|
try:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
rows = db_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT market, playbook_json
|
||||||
|
FROM playbooks
|
||||||
|
WHERE date = ?
|
||||||
|
ORDER BY market
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>🧠 Today's Scenarios</b>\n\nNo playbooks found for today."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ["<b>🧠 Today's Scenarios</b>", ""]
|
||||||
|
for market, playbook_json in rows:
|
||||||
|
lines.append(f"<b>{market}</b>")
|
||||||
|
playbook_data = {}
|
||||||
|
try:
|
||||||
|
playbook_data = json.loads(playbook_json)
|
||||||
|
except Exception:
|
||||||
|
playbook_data = {}
|
||||||
|
|
||||||
|
stock_playbooks = playbook_data.get("stock_playbooks", [])
|
||||||
|
if not stock_playbooks:
|
||||||
|
lines.append("- No scenarios")
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for stock_pb in stock_playbooks:
|
||||||
|
stock_code = stock_pb.get("stock_code", "N/A")
|
||||||
|
scenarios = stock_pb.get("scenarios", [])
|
||||||
|
for sc in scenarios:
|
||||||
|
action = sc.get("action", "HOLD")
|
||||||
|
confidence = sc.get("confidence", 0)
|
||||||
|
lines.append(f"- {stock_code}: {action} ({confidence})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
await telegram.send_message("\n".join(lines).strip())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in /scenarios handler: %s", exc)
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>⚠️ Error</b>\n\nFailed to retrieve scenarios."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_review() -> None:
|
||||||
|
"""Handle /review command - show recent scorecards."""
|
||||||
|
try:
|
||||||
|
rows = db_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe, key, value
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = 'L6_DAILY' AND key LIKE 'scorecard_%'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>📝 Recent Reviews</b>\n\nNo scorecards available."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ["<b>📝 Recent Reviews</b>", ""]
|
||||||
|
for timeframe, key, value in rows:
|
||||||
|
scorecard = json.loads(value)
|
||||||
|
market = key.replace("scorecard_", "")
|
||||||
|
total_pnl = float(scorecard.get("total_pnl", 0.0))
|
||||||
|
win_rate = float(scorecard.get("win_rate", 0.0))
|
||||||
|
decisions = int(scorecard.get("total_decisions", 0))
|
||||||
|
lines.append(
|
||||||
|
f"- {timeframe} {market}: P&L {total_pnl:+.2f}, "
|
||||||
|
f"Win {win_rate:.2f}%, Decisions {decisions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await telegram.send_message("\n".join(lines))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in /review handler: %s", exc)
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>⚠️ Error</b>\n\nFailed to retrieve reviews."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_dashboard() -> None:
|
||||||
|
"""Handle /dashboard command - show dashboard URL if enabled."""
|
||||||
|
if not settings.DASHBOARD_ENABLED:
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>🖥️ Dashboard</b>\n\nDashboard is not enabled."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"http://{settings.DASHBOARD_HOST}:{settings.DASHBOARD_PORT}"
|
||||||
|
await telegram.send_message(
|
||||||
|
"<b>🖥️ Dashboard</b>\n\n"
|
||||||
|
f"<b>URL:</b> {url}"
|
||||||
|
)
|
||||||
|
|
||||||
command_handler.register_command("help", handle_help)
|
command_handler.register_command("help", handle_help)
|
||||||
command_handler.register_command("stop", handle_stop)
|
command_handler.register_command("stop", handle_stop)
|
||||||
command_handler.register_command("resume", handle_resume)
|
command_handler.register_command("resume", handle_resume)
|
||||||
command_handler.register_command("status", handle_status)
|
command_handler.register_command("status", handle_status)
|
||||||
command_handler.register_command("positions", handle_positions)
|
command_handler.register_command("positions", handle_positions)
|
||||||
|
command_handler.register_command("report", handle_report)
|
||||||
|
command_handler.register_command("scenarios", handle_scenarios)
|
||||||
|
command_handler.register_command("review", handle_review)
|
||||||
|
command_handler.register_command("dashboard", handle_dashboard)
|
||||||
|
|
||||||
# Initialize volatility hunter
|
# Initialize volatility hunter
|
||||||
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ MARKETS: dict[str, MarketInfo] = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MARKET_SHORTHAND: dict[str, list[str]] = {
|
||||||
|
"US": ["US_NASDAQ", "US_NYSE", "US_AMEX"],
|
||||||
|
"CN": ["CN_SHA", "CN_SZA"],
|
||||||
|
"VN": ["VN_HAN", "VN_HCM"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_market_codes(codes: list[str]) -> list[str]:
|
||||||
|
"""Expand shorthand market codes into concrete exchange market codes."""
|
||||||
|
expanded: list[str] = []
|
||||||
|
for code in codes:
|
||||||
|
if code in MARKET_SHORTHAND:
|
||||||
|
expanded.extend(MARKET_SHORTHAND[code])
|
||||||
|
else:
|
||||||
|
expanded.append(code)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
|
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from src.evolution.daily_review import DailyReviewer
|
|||||||
from src.evolution.scorecard import DailyScorecard
|
from src.evolution.scorecard import DailyScorecard
|
||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
TODAY = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_conn() -> sqlite3.Connection:
|
def db_conn() -> sqlite3.Connection:
|
||||||
@@ -116,7 +120,7 @@ def test_generate_scorecard_market_scoped(
|
|||||||
exchange_code="NASDAQ",
|
exchange_code="NASDAQ",
|
||||||
)
|
)
|
||||||
|
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
|
|
||||||
assert scorecard.market == "KR"
|
assert scorecard.market == "KR"
|
||||||
assert scorecard.total_decisions == 2
|
assert scorecard.total_decisions == 2
|
||||||
@@ -158,7 +162,7 @@ def test_generate_scorecard_top_winners_and_losers(
|
|||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
assert scorecard.top_winners == ["005930", "000660"]
|
assert scorecard.top_winners == ["005930", "000660"]
|
||||||
assert scorecard.top_losers == ["035420", "051910"]
|
assert scorecard.top_losers == ["035420", "051910"]
|
||||||
|
|
||||||
@@ -167,7 +171,7 @@ def test_generate_scorecard_empty_day(
|
|||||||
db_conn: sqlite3.Connection, context_store: ContextStore,
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
) -> None:
|
) -> None:
|
||||||
reviewer = DailyReviewer(db_conn, context_store)
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
|
|
||||||
assert scorecard.total_decisions == 0
|
assert scorecard.total_decisions == 0
|
||||||
assert scorecard.total_pnl == 0.0
|
assert scorecard.total_pnl == 0.0
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
"""Tests for FastAPI dashboard endpoints."""
|
"""Tests for dashboard endpoint handlers."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
pytest.importorskip("fastapi")
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.dashboard.app import create_dashboard_app
|
from src.dashboard.app import create_dashboard_app
|
||||||
from src.db import init_db
|
from src.db import init_db
|
||||||
|
|
||||||
|
|
||||||
def _seed_db(conn: sqlite3.Connection) -> None:
|
def _seed_db(conn: sqlite3.Connection) -> None:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO playbooks (
|
INSERT INTO playbooks (
|
||||||
@@ -34,6 +38,24 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO playbooks (
|
||||||
|
date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
today,
|
||||||
|
"US_NASDAQ",
|
||||||
|
"ready",
|
||||||
|
json.dumps({"market": "US_NASDAQ", "stock_playbooks": []}),
|
||||||
|
f"{today}T08:30:00+00:00",
|
||||||
|
100,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
@@ -71,7 +93,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
"d-kr-1",
|
"d-kr-1",
|
||||||
"2026-02-14T09:10:00+00:00",
|
f"{today}T09:10:00+00:00",
|
||||||
"005930",
|
"005930",
|
||||||
"KR",
|
"KR",
|
||||||
"KRX",
|
"KRX",
|
||||||
@@ -91,9 +113,9 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
"d-us-1",
|
"d-us-1",
|
||||||
"2026-02-14T21:10:00+00:00",
|
f"{today}T21:10:00+00:00",
|
||||||
"AAPL",
|
"AAPL",
|
||||||
"US",
|
"US_NASDAQ",
|
||||||
"NASDAQ",
|
"NASDAQ",
|
||||||
"SELL",
|
"SELL",
|
||||||
80,
|
80,
|
||||||
@@ -110,7 +132,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
"2026-02-14T09:11:00+00:00",
|
f"{today}T09:11:00+00:00",
|
||||||
"005930",
|
"005930",
|
||||||
"BUY",
|
"BUY",
|
||||||
85,
|
85,
|
||||||
@@ -132,7 +154,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
"2026-02-14T21:11:00+00:00",
|
f"{today}T21:11:00+00:00",
|
||||||
"AAPL",
|
"AAPL",
|
||||||
"SELL",
|
"SELL",
|
||||||
80,
|
80,
|
||||||
@@ -140,7 +162,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
1,
|
1,
|
||||||
200,
|
200,
|
||||||
-1.0,
|
-1.0,
|
||||||
"US",
|
"US_NASDAQ",
|
||||||
"NASDAQ",
|
"NASDAQ",
|
||||||
None,
|
None,
|
||||||
"d-us-1",
|
"d-us-1",
|
||||||
@@ -149,122 +171,128 @@ def _seed_db(conn: sqlite3.Connection) -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _client(tmp_path: Path) -> TestClient:
|
def _app(tmp_path: Path) -> Any:
|
||||||
db_path = tmp_path / "dashboard_test.db"
|
db_path = tmp_path / "dashboard_test.db"
|
||||||
conn = init_db(str(db_path))
|
conn = init_db(str(db_path))
|
||||||
_seed_db(conn)
|
_seed_db(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
app = create_dashboard_app(str(db_path))
|
return create_dashboard_app(str(db_path))
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
def _endpoint(app: Any, path: str) -> Callable[..., Any]:
|
||||||
|
for route in app.routes:
|
||||||
|
if getattr(route, "path", None) == path:
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"route not found: {path}")
|
||||||
|
|
||||||
|
|
||||||
def test_index_serves_html(tmp_path: Path) -> None:
|
def test_index_serves_html(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/")
|
index = _endpoint(app, "/")
|
||||||
assert resp.status_code == 200
|
resp = index()
|
||||||
assert "The Ouroboros Dashboard API" in resp.text
|
assert isinstance(resp, FileResponse)
|
||||||
|
assert "index.html" in str(resp.path)
|
||||||
|
|
||||||
|
|
||||||
def test_status_endpoint(tmp_path: Path) -> None:
|
def test_status_endpoint(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/status")
|
get_status = _endpoint(app, "/api/status")
|
||||||
assert resp.status_code == 200
|
body = get_status()
|
||||||
body = resp.json()
|
|
||||||
assert "KR" in body["markets"]
|
assert "KR" in body["markets"]
|
||||||
assert "US" in body["markets"]
|
assert "US_NASDAQ" in body["markets"]
|
||||||
assert "totals" in body
|
assert "totals" in body
|
||||||
|
|
||||||
|
|
||||||
def test_playbook_found(tmp_path: Path) -> None:
|
def test_playbook_found(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/playbook/2026-02-14?market=KR")
|
get_playbook = _endpoint(app, "/api/playbook/{date_str}")
|
||||||
assert resp.status_code == 200
|
body = get_playbook("2026-02-14", market="KR")
|
||||||
assert resp.json()["market"] == "KR"
|
assert body["market"] == "KR"
|
||||||
|
|
||||||
|
|
||||||
def test_playbook_not_found(tmp_path: Path) -> None:
|
def test_playbook_not_found(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/playbook/2026-02-15?market=KR")
|
get_playbook = _endpoint(app, "/api/playbook/{date_str}")
|
||||||
assert resp.status_code == 404
|
with pytest.raises(HTTPException, match="playbook not found"):
|
||||||
|
get_playbook("2026-02-15", market="KR")
|
||||||
|
|
||||||
|
|
||||||
def test_scorecard_found(tmp_path: Path) -> None:
|
def test_scorecard_found(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/scorecard/2026-02-14?market=KR")
|
get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
|
||||||
assert resp.status_code == 200
|
body = get_scorecard("2026-02-14", market="KR")
|
||||||
assert resp.json()["scorecard"]["total_pnl"] == 1.5
|
assert body["scorecard"]["total_pnl"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
def test_scorecard_not_found(tmp_path: Path) -> None:
|
def test_scorecard_not_found(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/scorecard/2026-02-15?market=KR")
|
get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
|
||||||
assert resp.status_code == 404
|
with pytest.raises(HTTPException, match="scorecard not found"):
|
||||||
|
get_scorecard("2026-02-15", market="KR")
|
||||||
|
|
||||||
|
|
||||||
def test_performance_all(tmp_path: Path) -> None:
|
def test_performance_all(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/performance?market=all")
|
get_performance = _endpoint(app, "/api/performance")
|
||||||
assert resp.status_code == 200
|
body = get_performance(market="all")
|
||||||
body = resp.json()
|
|
||||||
assert body["market"] == "all"
|
assert body["market"] == "all"
|
||||||
assert body["combined"]["total_trades"] == 2
|
assert body["combined"]["total_trades"] == 2
|
||||||
assert len(body["by_market"]) == 2
|
assert len(body["by_market"]) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_performance_market_filter(tmp_path: Path) -> None:
|
def test_performance_market_filter(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/performance?market=KR")
|
get_performance = _endpoint(app, "/api/performance")
|
||||||
assert resp.status_code == 200
|
body = get_performance(market="KR")
|
||||||
body = resp.json()
|
|
||||||
assert body["market"] == "KR"
|
assert body["market"] == "KR"
|
||||||
assert body["metrics"]["total_trades"] == 1
|
assert body["metrics"]["total_trades"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_performance_empty_market(tmp_path: Path) -> None:
|
def test_performance_empty_market(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/performance?market=JP")
|
get_performance = _endpoint(app, "/api/performance")
|
||||||
assert resp.status_code == 200
|
body = get_performance(market="JP")
|
||||||
assert resp.json()["metrics"]["total_trades"] == 0
|
assert body["metrics"]["total_trades"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_context_layer_all(tmp_path: Path) -> None:
|
def test_context_layer_all(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/context/L7_REALTIME")
|
get_context_layer = _endpoint(app, "/api/context/{layer}")
|
||||||
assert resp.status_code == 200
|
body = get_context_layer("L7_REALTIME", timeframe=None, limit=100)
|
||||||
body = resp.json()
|
|
||||||
assert body["layer"] == "L7_REALTIME"
|
assert body["layer"] == "L7_REALTIME"
|
||||||
assert body["count"] == 1
|
assert body["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_context_layer_timeframe_filter(tmp_path: Path) -> None:
|
def test_context_layer_timeframe_filter(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/context/L6_DAILY?timeframe=2026-02-14")
|
get_context_layer = _endpoint(app, "/api/context/{layer}")
|
||||||
assert resp.status_code == 200
|
body = get_context_layer("L6_DAILY", timeframe="2026-02-14", limit=100)
|
||||||
body = resp.json()
|
|
||||||
assert body["count"] == 1
|
assert body["count"] == 1
|
||||||
assert body["entries"][0]["key"] == "scorecard_KR"
|
assert body["entries"][0]["key"] == "scorecard_KR"
|
||||||
|
|
||||||
|
|
||||||
def test_decisions_endpoint(tmp_path: Path) -> None:
|
def test_decisions_endpoint(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/decisions?market=KR")
|
get_decisions = _endpoint(app, "/api/decisions")
|
||||||
assert resp.status_code == 200
|
body = get_decisions(market="KR", limit=50)
|
||||||
body = resp.json()
|
|
||||||
assert body["count"] == 1
|
assert body["count"] == 1
|
||||||
assert body["decisions"][0]["decision_id"] == "d-kr-1"
|
assert body["decisions"][0]["decision_id"] == "d-kr-1"
|
||||||
|
|
||||||
|
|
||||||
def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None:
|
def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/scenarios/active?market=KR&date_str=2026-02-14")
|
get_active_scenarios = _endpoint(app, "/api/scenarios/active")
|
||||||
assert resp.status_code == 200
|
body = get_active_scenarios(
|
||||||
body = resp.json()
|
market="KR",
|
||||||
|
date_str=datetime.now(UTC).date().isoformat(),
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
assert body["count"] == 1
|
assert body["count"] == 1
|
||||||
assert body["matches"][0]["stock_code"] == "005930"
|
assert body["matches"][0]["stock_code"] == "005930"
|
||||||
|
|
||||||
|
|
||||||
def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None:
|
def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None:
|
||||||
client = _client(tmp_path)
|
app = _app(tmp_path)
|
||||||
resp = client.get("/api/scenarios/active?market=US&date_str=2026-02-14")
|
get_active_scenarios = _endpoint(app, "/api/scenarios/active")
|
||||||
assert resp.status_code == 200
|
body = get_active_scenarios(market="US", date_str="2026-02-14", limit=50)
|
||||||
assert resp.json()["count"] == 0
|
assert body["count"] == 0
|
||||||
|
|||||||
60
tests/test_db.py
Normal file
60
tests/test_db.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for database helper functions."""
|
||||||
|
|
||||||
|
from src.db import get_open_position, init_db, log_trade
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_open_position_returns_latest_buy() -> None:
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=2,
|
||||||
|
price=70000.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id="d-buy-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
position = get_open_position(conn, "005930", "KR")
|
||||||
|
assert position is not None
|
||||||
|
assert position["decision_id"] == "d-buy-1"
|
||||||
|
assert position["price"] == 70000.0
|
||||||
|
assert position["quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_open_position_returns_none_when_latest_is_sell() -> None:
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=1,
|
||||||
|
price=70000.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id="d-buy-1",
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="SELL",
|
||||||
|
confidence=95,
|
||||||
|
rationale="exit",
|
||||||
|
quantity=1,
|
||||||
|
price=71000.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id="d-sell-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert get_open_position(conn, "005930", "KR") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_open_position_returns_none_when_no_trades() -> None:
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
|
||||||
@@ -116,6 +116,7 @@ class TestTradingCycleTelegramIntegration:
|
|||||||
"output1": {
|
"output1": {
|
||||||
"stck_prpr": "50000",
|
"stck_prpr": "50000",
|
||||||
"frgn_ntby_qty": "100",
|
"frgn_ntby_qty": "100",
|
||||||
|
"prdy_ctrt": "1.23",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -747,7 +748,7 @@ class TestScenarioEngineIntegration:
|
|||||||
broker = MagicMock()
|
broker = MagicMock()
|
||||||
broker.get_orderbook = AsyncMock(
|
broker.get_orderbook = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
"output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100"}
|
"output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100", "prdy_ctrt": "2.50"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
broker.get_balance = AsyncMock(
|
broker.get_balance = AsyncMock(
|
||||||
@@ -830,6 +831,7 @@ class TestScenarioEngineIntegration:
|
|||||||
assert market_data["rsi"] == 25.0
|
assert market_data["rsi"] == 25.0
|
||||||
assert market_data["volume_ratio"] == 3.5
|
assert market_data["volume_ratio"] == 3.5
|
||||||
assert market_data["current_price"] == 50000.0
|
assert market_data["current_price"] == 50000.0
|
||||||
|
assert market_data["price_change_pct"] == 2.5
|
||||||
|
|
||||||
# Portfolio data should include pnl
|
# Portfolio data should include pnl
|
||||||
assert "portfolio_pnl_pct" in portfolio_data
|
assert "portfolio_pnl_pct" in portfolio_data
|
||||||
@@ -1232,6 +1234,107 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
|
|||||||
assert updated_buy.outcome_accuracy == 1
|
assert updated_buy.outcome_accuracy == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
|
||||||
|
"""HOLD decision should be overridden to SELL when stop-loss threshold is breached."""
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
buy_decision_id = decision_logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
conn=db_conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id=buy_decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_orderbook = AsyncMock(
|
||||||
|
return_value={"output1": {"stck_prpr": "95", "frgn_ntby_qty": "0", "prdy_ctrt": "-5.0"}}
|
||||||
|
)
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "100000",
|
||||||
|
"dnca_tot_amt": "10000",
|
||||||
|
"pchs_amt_smtl_amt": "90000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
scenario = StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=30),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=88,
|
||||||
|
stop_loss_pct=-2.0,
|
||||||
|
rationale="stop loss policy",
|
||||||
|
)
|
||||||
|
playbook = DayPlaybook(
|
||||||
|
date=date(2026, 2, 8),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[
|
||||||
|
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_called_once()
|
||||||
|
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
async def test_handle_market_close_runs_daily_review_flow() -> None:
|
||||||
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
"""Market close should aggregate, create scorecard, lessons, and notify."""
|
||||||
@@ -1427,7 +1530,7 @@ async def test_run_evolution_loop_notifies_when_pr_generated() -> None:
|
|||||||
await _run_evolution_loop(
|
await _run_evolution_loop(
|
||||||
evolution_optimizer=optimizer,
|
evolution_optimizer=optimizer,
|
||||||
telegram=telegram,
|
telegram=telegram,
|
||||||
market_code="US",
|
market_code="US_NASDAQ",
|
||||||
market_date="2026-02-14",
|
market_date="2026-02-14",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1451,7 +1554,7 @@ async def test_run_evolution_loop_notification_error_is_ignored() -> None:
|
|||||||
await _run_evolution_loop(
|
await _run_evolution_loop(
|
||||||
evolution_optimizer=optimizer,
|
evolution_optimizer=optimizer,
|
||||||
telegram=telegram,
|
telegram=telegram,
|
||||||
market_code="US",
|
market_code="US_NYSE",
|
||||||
market_date="2026-02-14",
|
market_date="2026-02-14",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
|
|
||||||
from src.markets.schedule import (
|
from src.markets.schedule import (
|
||||||
MARKETS,
|
MARKETS,
|
||||||
|
expand_market_codes,
|
||||||
get_next_market_open,
|
get_next_market_open,
|
||||||
get_open_markets,
|
get_open_markets,
|
||||||
is_market_open,
|
is_market_open,
|
||||||
@@ -199,3 +200,28 @@ class TestGetNextMarketOpen:
|
|||||||
enabled_markets=["INVALID", "KR"], now=test_time
|
enabled_markets=["INVALID", "KR"], now=test_time
|
||||||
)
|
)
|
||||||
assert market.code == "KR"
|
assert market.code == "KR"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandMarketCodes:
|
||||||
|
"""Test shorthand market expansion."""
|
||||||
|
|
||||||
|
def test_expand_us_shorthand(self) -> None:
|
||||||
|
assert expand_market_codes(["US"]) == ["US_NASDAQ", "US_NYSE", "US_AMEX"]
|
||||||
|
|
||||||
|
def test_expand_cn_shorthand(self) -> None:
|
||||||
|
assert expand_market_codes(["CN"]) == ["CN_SHA", "CN_SZA"]
|
||||||
|
|
||||||
|
def test_expand_vn_shorthand(self) -> None:
|
||||||
|
assert expand_market_codes(["VN"]) == ["VN_HAN", "VN_HCM"]
|
||||||
|
|
||||||
|
def test_expand_mixed_codes(self) -> None:
|
||||||
|
assert expand_market_codes(["KR", "US", "JP"]) == [
|
||||||
|
"KR",
|
||||||
|
"US_NASDAQ",
|
||||||
|
"US_NYSE",
|
||||||
|
"US_AMEX",
|
||||||
|
"JP",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_expand_preserves_unknown_code(self) -> None:
|
||||||
|
assert expand_market_codes(["KR", "UNKNOWN"]) == ["KR", "UNKNOWN"]
|
||||||
|
|||||||
@@ -682,6 +682,10 @@ class TestBasicCommands:
|
|||||||
"/help - Show available commands\n"
|
"/help - Show available commands\n"
|
||||||
"/status - Trading status (mode, markets, P&L)\n"
|
"/status - Trading status (mode, markets, P&L)\n"
|
||||||
"/positions - Current holdings\n"
|
"/positions - Current holdings\n"
|
||||||
|
"/report - Daily summary report\n"
|
||||||
|
"/scenarios - Today's playbook scenarios\n"
|
||||||
|
"/review - Recent scorecards\n"
|
||||||
|
"/dashboard - Dashboard URL/status\n"
|
||||||
"/stop - Pause trading\n"
|
"/stop - Pause trading\n"
|
||||||
"/resume - Resume trading"
|
"/resume - Resume trading"
|
||||||
)
|
)
|
||||||
@@ -707,10 +711,106 @@ class TestBasicCommands:
|
|||||||
assert "/help" in payload["text"]
|
assert "/help" in payload["text"]
|
||||||
assert "/status" in payload["text"]
|
assert "/status" in payload["text"]
|
||||||
assert "/positions" in payload["text"]
|
assert "/positions" in payload["text"]
|
||||||
|
assert "/report" in payload["text"]
|
||||||
|
assert "/scenarios" in payload["text"]
|
||||||
|
assert "/review" in payload["text"]
|
||||||
|
assert "/dashboard" in payload["text"]
|
||||||
assert "/stop" in payload["text"]
|
assert "/stop" in payload["text"]
|
||||||
assert "/resume" in payload["text"]
|
assert "/resume" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtendedCommands:
|
||||||
|
"""Test additional bot commands."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_report_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_report() -> None:
|
||||||
|
await client.send_message("<b>📈 Daily Report</b>\n\nTrades: 1")
|
||||||
|
|
||||||
|
handler.register_command("report", mock_report)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/report"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Daily Report" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scenarios_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_scenarios() -> None:
|
||||||
|
await client.send_message("<b>🧠 Today's Scenarios</b>\n\n- AAPL: BUY (85)")
|
||||||
|
|
||||||
|
handler.register_command("scenarios", mock_scenarios)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/scenarios"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Today's Scenarios" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_review() -> None:
|
||||||
|
await client.send_message("<b>📝 Recent Reviews</b>\n\n- 2026-02-14 KR")
|
||||||
|
|
||||||
|
handler.register_command("review", mock_review)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/review"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Recent Reviews" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dashboard_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_dashboard() -> None:
|
||||||
|
await client.send_message("<b>🖥️ Dashboard</b>\n\nURL: http://127.0.0.1:8080")
|
||||||
|
|
||||||
|
handler.register_command("dashboard", mock_dashboard)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/dashboard"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Dashboard" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
class TestGetUpdates:
|
class TestGetUpdates:
|
||||||
"""Test getUpdates API interaction."""
|
"""Test getUpdates API interaction."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user