Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b163eab9 | |||
| 1c192ecdea | |||
| 82e18a9a22 | |||
| 6d9937c590 | |||
| ef6f9c74f6 | |||
| 24ec410f3d | |||
| 024122d310 | |||
| c6f269e30a | |||
| 97447a551f | |||
| fc0d341d15 | |||
| 688ee3350d | |||
| f40da64a05 | |||
| 475950be36 | |||
| 27a56e65b7 | |||
| a9986fbc4c | |||
| ee14f5fbe4 | |||
| 3f45fbc36e | |||
| 87949f42b5 | |||
| 4eb23a41ca | |||
| 6db27fd634 | |||
| a3fe301308 | |||
| bcc9b76212 | |||
| 7172de3623 | |||
| 6582ffc02a | |||
| f8ff3a2c46 | |||
| ecc7deed08 | |||
| ee0787683b | |||
| f8d93de299 | |||
| 7db7f74713 | |||
| fbb56400af | |||
| 744e9d6a7a | |||
| 521d1c0b3d | |||
| d49e45b308 | |||
| 49dc382a39 | |||
| 15b9840204 | |||
| 534582972d | |||
| 890ed68a0f | |||
| 92efa934a2 | |||
| cc001fa263 | |||
| 606664404b | |||
| a7f9b27a55 | |||
| 34991eca2d | |||
| d833d386d0 | |||
| daec1a0e1b | |||
| a9b8f46187 | |||
| eb48b7eb07 | |||
| 187cdf99c0 | |||
| 21da79cb46 | |||
| 49637f1b02 | |||
| e15e4c6e20 | |||
| 6e666844f0 | |||
| 3f6c21c86e | |||
| 6fb43fb411 | |||
| 5d89c6ad02 | |||
| 1d90580854 | |||
| c25e3bee7a | |||
| 6e20a924db | |||
| 780ccee1fe | |||
| 129e2ec2d7 | |||
| 4cd1cab466 | |||
| be043a85e3 | |||
| 1c46d7b558 | |||
| 7f0c9b9a27 | |||
| 42d45e85fb | |||
| e7d1069222 | |||
| a274ef448a |
+53
-69
@@ -1,13 +1,17 @@
|
|||||||
name: Validators (Pull Requests Only)
|
name: Validators (Pushes and Pull Requests)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Phase 3: Validator pipeline
|
# Validator pipeline. Independent validation jobs run in parallel.
|
||||||
# Main branch push validation moved to merge-to-main.yml
|
|
||||||
# This workflow runs validators only on PRs for feedback
|
concurrency:
|
||||||
|
group: quantengine-ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-core:
|
validate-core:
|
||||||
@@ -32,38 +36,15 @@ jobs:
|
|||||||
- name: Setup Python Environment
|
- name: Setup Python Environment
|
||||||
run: |
|
run: |
|
||||||
# 순수 Python 패키지만 설치 (numpy/pandas 제외 — ARMv7l 휠 없음)
|
# 순수 Python 패키지만 설치 (numpy/pandas 제외 — ARMv7l 휠 없음)
|
||||||
VENV_BASE=$HOME/python_venv
|
PYTHON_DEPS="$HOME/python_deps/$(md5sum tools/validate_specs.py | cut -d' ' -f1)"
|
||||||
REQ_HASH=$(md5sum tools/validate_specs.py 2>/dev/null | cut -d' ' -f1 || echo "default")
|
mkdir -p "$PYTHON_DEPS"
|
||||||
VENV="$VENV_BASE/$REQ_HASH"
|
/usr/bin/python3 --version
|
||||||
|
/usr/bin/python3 -m pip --version
|
||||||
if [ ! -f "$VENV/bin/python" ]; then
|
/usr/bin/python3 -m pip install --disable-pip-version-check --quiet \
|
||||||
echo "=== venv 신규 생성: $REQ_HASH ==="
|
--target "$PYTHON_DEPS" requests pyyaml openpyxl pytest
|
||||||
mkdir -p "$VENV_BASE"
|
export PYTHONPATH="$PYTHON_DEPS:${PYTHONPATH:-}"
|
||||||
/usr/bin/python3 -m venv "$VENV"
|
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||||
|
/usr/bin/python3 -c 'import requests, yaml, openpyxl, pytest; print("Python dependencies: PASS")'
|
||||||
# venv 내 pip 확인 및 복구
|
|
||||||
if [ ! -f "$VENV/bin/pip" ]; then
|
|
||||||
echo "pip missing in venv, installing via get-pip.py..."
|
|
||||||
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
|
|
||||||
"$VENV/bin/python" get-pip.py --quiet
|
|
||||||
rm get-pip.py
|
|
||||||
fi
|
|
||||||
|
|
||||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
|
||||||
"$VENV/bin/pip" install requests pyyaml openpyxl --quiet
|
|
||||||
|
|
||||||
# 오래된 venv 정리 (최근 2개만 유지)
|
|
||||||
ls -dt "$VENV_BASE"/*/ 2>/dev/null | tail -n +3 | xargs rm -rf 2>/dev/null || true
|
|
||||||
else
|
|
||||||
echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ==="
|
|
||||||
"$VENV/bin/python" - <<'PY'
|
|
||||||
import importlib
|
|
||||||
for mod in ("requests", "yaml", "openpyxl"):
|
|
||||||
importlib.import_module(mod)
|
|
||||||
print("venv dependency import check: PASS")
|
|
||||||
PY
|
|
||||||
fi
|
|
||||||
echo "$VENV/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install Node Dependencies
|
- name: Install Node Dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -86,7 +67,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "=== npm install (최초 or lock 변경) ==="
|
echo "=== npm install (최초 or lock 변경) ==="
|
||||||
npm install --quiet
|
npm ci --quiet
|
||||||
# 캐시 저장
|
# 캐시 저장
|
||||||
mkdir -p "$CACHE_DIR"
|
mkdir -p "$CACHE_DIR"
|
||||||
cp -r node_modules "$CACHE_DIR/node_modules"
|
cp -r node_modules "$CACHE_DIR/node_modules"
|
||||||
@@ -120,6 +101,29 @@ jobs:
|
|||||||
- name: Validate Platform Transition WBS
|
- name: Validate Platform Transition WBS
|
||||||
run: python3 tools/validate_platform_transition_wbs_v1.py
|
run: python3 tools/validate_platform_transition_wbs_v1.py
|
||||||
|
|
||||||
|
- name: Validate Schema Model Generation
|
||||||
|
run: python3 tools/generate_schema_model_generation_evidence_v1.py && python3 tools/validate_schema_model_generation_v1.py
|
||||||
|
|
||||||
|
- name: Validate Market Time Series Schema
|
||||||
|
run: python3 tools/validate_market_time_series_schema_v1.py
|
||||||
|
|
||||||
|
- name: Generate DONE WBS Verdicts
|
||||||
|
run: |
|
||||||
|
for task in QE-M0-01 QE-M0-02 QE-M0-03 QE-M0-04 QE-M0-05 QE-M0-06; do
|
||||||
|
python3 tools/verify_wbs_task_v1.py --task "$task"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Validate Quant Engine WBS
|
||||||
|
run: python3 tools/validate_quant_engine_wbs_v1.py
|
||||||
|
|
||||||
|
- name: Setup .NET SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Run .NET Unit Tests
|
||||||
|
run: dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo
|
||||||
|
|
||||||
- name: Build Calibration Priority Backlog
|
- name: Build Calibration Priority Backlog
|
||||||
run: python3 tools/build_calibration_priority_v1.py
|
run: python3 tools/build_calibration_priority_v1.py
|
||||||
|
|
||||||
@@ -168,8 +172,9 @@ jobs:
|
|||||||
- name: Ensure Temp Directory and Mock Packet
|
- name: Ensure Temp Directory and Mock Packet
|
||||||
run: |
|
run: |
|
||||||
mkdir -p Temp
|
mkdir -p Temp
|
||||||
|
python3 -c 'import json; json.dump({"order_blueprint_json":{},"cash_recovery_plan_json":{},"per_ticker":[{"ticker":"DATA_MISSING","gate":"DATA_MISSING"}],"meta":{"formulas_run":[],"source_file":"GatherTradingData.json"}},open("Temp/computed_harness_v1.json","w"),ensure_ascii=False,indent=2)'
|
||||||
if [ ! -f Temp/final_decision_packet_active.json ]; then
|
if [ ! -f Temp/final_decision_packet_active.json ]; then
|
||||||
echo '{"formula_id":"FINAL_DECISION_PACKET_V2","meta":{"generated_at":"2026-06-29T00:00:00Z"},"canonical_metrics":{},"portfolio_snapshot":{},"order_table":[]}' > Temp/final_decision_packet_active.json
|
python3 -c 'import json; json.dump({"formula_id":"FINAL_DECISION_PACKET_V2","meta":{"generated_at":"2026-06-29T00:00:00Z"},"canonical_metrics":{"total_asset_krw":None},"portfolio_snapshot":{},"order_table":[],"pass_100":{"gate":"DATA_MISSING","score_0_100":None},"execution_readiness":{"gate":"DATA_MISSING","min_axis_score":None},"prediction":{"match_rate_pct":None}},open("Temp/final_decision_packet_active.json","w"),ensure_ascii=False,indent=2)'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Validate Replay Live Separation
|
- name: Validate Replay Live Separation
|
||||||
@@ -197,7 +202,7 @@ jobs:
|
|||||||
run: python3 tools/validate_postgresql_history_contract_v1.py
|
run: python3 tools/validate_postgresql_history_contract_v1.py
|
||||||
|
|
||||||
- name: Package Operational Report Artifacts
|
- name: Package Operational Report Artifacts
|
||||||
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/operational_report.md Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json
|
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json
|
||||||
|
|
||||||
- name: Upload Operational Report Artifacts
|
- name: Upload Operational Report Artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -213,8 +218,6 @@ jobs:
|
|||||||
|
|
||||||
validate-ui-and-storage:
|
validate-ui-and-storage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: validate-core
|
|
||||||
if: github.event_name != 'push'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
@@ -224,47 +227,28 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Python Environment
|
- name: Setup Python Environment
|
||||||
run: |
|
run: |
|
||||||
VENV_BASE=$HOME/python_venv
|
PYTHON_DEPS="$HOME/python_deps/$(md5sum tools/validate_snapshot_admin_web_v1.py | cut -d' ' -f1)"
|
||||||
REQ_HASH=$(md5sum tools/validate_snapshot_admin_web_v1.py 2>/dev/null | cut -d' ' -f1 || echo "default")
|
mkdir -p "$PYTHON_DEPS"
|
||||||
VENV="$VENV_BASE/$REQ_HASH"
|
/usr/bin/python3 --version
|
||||||
|
/usr/bin/python3 -m pip --version
|
||||||
if [ ! -f "$VENV/bin/python" ]; then
|
/usr/bin/python3 -m pip install --disable-pip-version-check --quiet \
|
||||||
echo "=== venv 신규 생성: $REQ_HASH ==="
|
--target "$PYTHON_DEPS" requests pyyaml openpyxl pytest
|
||||||
mkdir -p "$VENV_BASE"
|
export PYTHONPATH="$PYTHON_DEPS:${PYTHONPATH:-}"
|
||||||
/usr/bin/python3 -m venv "$VENV"
|
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||||
|
/usr/bin/python3 -c 'import requests, yaml, openpyxl, pytest; print("Python dependencies: PASS")'
|
||||||
if [ ! -f "$VENV/bin/pip" ]; then
|
|
||||||
echo "pip missing in venv, installing via get-pip.py..."
|
|
||||||
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
|
|
||||||
"$VENV/bin/python" get-pip.py --quiet
|
|
||||||
rm get-pip.py
|
|
||||||
fi
|
|
||||||
|
|
||||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
|
||||||
"$VENV/bin/pip" install requests pyyaml openpyxl --quiet
|
|
||||||
else
|
|
||||||
echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ==="
|
|
||||||
fi
|
|
||||||
echo "$VENV/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Validate Snapshot Admin Web UI
|
- name: Validate Snapshot Admin Web UI
|
||||||
if: needs.validate-core.result == 'success'
|
|
||||||
run: python3 tools/validate_snapshot_admin_web_v1.py
|
run: python3 tools/validate_snapshot_admin_web_v1.py
|
||||||
|
|
||||||
- name: Validate Storage Backend Contracts
|
- name: Validate Storage Backend Contracts
|
||||||
if: needs.validate-core.result == 'success'
|
|
||||||
run: python3 -m pytest tests/unit/test_storage_backend_v1.py tests/unit/test_validate_kis_api_credentials_v1.py tests/unit/test_qualitative_sell_strategy_store_v1.py tests/unit/test_kis_api_client_v1.py tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
|
run: python3 -m pytest tests/unit/test_storage_backend_v1.py tests/unit/test_validate_kis_api_credentials_v1.py tests/unit/test_qualitative_sell_strategy_store_v1.py tests/unit/test_kis_api_client_v1.py tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
|
||||||
|
|
||||||
- name: Notify PR Result
|
- name: Notify PR Result
|
||||||
if: always() && github.event_name == 'pull_request'
|
if: always() && github.event_name == 'pull_request'
|
||||||
env:
|
env:
|
||||||
CORE_RESULT: ${{ needs.validate-core.result }}
|
|
||||||
STAGE_RESULT: ${{ job.status }}
|
STAGE_RESULT: ${{ job.status }}
|
||||||
run: |
|
run: |
|
||||||
STATUS="$STAGE_RESULT"
|
STATUS="$STAGE_RESULT"
|
||||||
if [ "$CORE_RESULT" != "success" ]; then
|
|
||||||
STATUS="failure"
|
|
||||||
fi
|
|
||||||
PR_NUM="${{ github.event.pull_request.number }}"
|
PR_NUM="${{ github.event.pull_request.number }}"
|
||||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
if [ "$STATUS" = "success" ]; then
|
if [ "$STATUS" = "success" ]; then
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Prepare Release"]
|
||||||
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
release:
|
release:
|
||||||
@@ -22,6 +25,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -204,12 +208,8 @@ jobs:
|
|||||||
echo "ERROR: QuantEngine.Web.dll not found"
|
echo "ERROR: QuantEngine.Web.dll not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ ! -f "$DEPLOY_DIR/appsettings.Production.json" ]; then
|
|
||||||
echo "ERROR: appsettings.Production.json not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✓ DLL verified"
|
echo "✓ DLL verified"
|
||||||
echo "✓ Config verified"
|
echo "✓ Runtime configuration is managed outside the release artifact"
|
||||||
|
|
||||||
# 3. Update Symlink
|
# 3. Update Symlink
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: KIS Data Collection Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 0 * * 1-5"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Validate mock credentials
|
||||||
|
env:
|
||||||
|
KIS_APP_Key_TEST: ${{ vars.KIS_APP_KEY_TEST }}
|
||||||
|
KIS_APP_Secret_TEST: ${{ vars.KIS_APP_SECRET_TEST }}
|
||||||
|
KIS_APP_Key: ${{ vars.KIS_APP_KEY }}
|
||||||
|
KIS_APP_Secret: ${{ vars.KIS_APP_SECRET }}
|
||||||
|
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
|
||||||
|
- name: Validate .NET PostgreSQL JSON cutover
|
||||||
|
run: python3 tools/validate_dotnet_postgresql_json_cutover_v1.py
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
name: Prepare Release
|
name: Prepare Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Validators (Pushes and Pull Requests)"]
|
||||||
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -14,6 +17,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
name: Build & Create Release
|
name: Build & Create Release
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -92,6 +96,10 @@ jobs:
|
|||||||
--no-restore \
|
--no-restore \
|
||||||
--no-build
|
--no-build
|
||||||
|
|
||||||
|
- name: Write Version Text
|
||||||
|
run: |
|
||||||
|
echo "${{ steps.metadata.outputs.version }}" > ./publish/version.txt
|
||||||
|
|
||||||
- name: Write Production Config
|
- name: Write Production Config
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./publish
|
mkdir -p ./publish
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Qualitative Sell Strategy Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "15 0 * * 1-5"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
DEPS="$RUNNER_TEMP/quantengine_sell_deps"
|
||||||
|
python3 -m pip install --disable-pip-version-check --quiet --target "$DEPS" pyyaml
|
||||||
|
echo "PYTHONPATH=$DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||||
|
- name: Validate mock credentials
|
||||||
|
env:
|
||||||
|
KIS_APP_Key_TEST: ${{ vars.KIS_APP_KEY_TEST }}
|
||||||
|
KIS_APP_Secret_TEST: ${{ vars.KIS_APP_SECRET_TEST }}
|
||||||
|
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
|
||||||
|
- name: Validate qualitative sell pipeline
|
||||||
|
run: python3 tools/validate_qualitative_sell_strategy_pipeline_v1.py
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Snapshot Admin Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "src/quant_engine/snapshot_admin_*.py"
|
||||||
|
- "tools/validate_snapshot_admin_*.py"
|
||||||
|
- "tests/unit/test_snapshot_admin_*.py"
|
||||||
|
- ".gitea/workflows/snapshot_admin.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
PYTHON_DEPS="$RUNNER_TEMP/quantengine_snapshot_admin_deps"
|
||||||
|
python3 -m pip install --disable-pip-version-check --quiet --target "$PYTHON_DEPS" pyyaml pytest
|
||||||
|
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||||
|
- name: Validate snapshot admin workflow
|
||||||
|
run: python3 tools/validate_snapshot_admin_workflow_v1.py
|
||||||
|
- name: Run snapshot admin tests
|
||||||
|
run: python3 -m pytest tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# 은퇴자산포트폴리오 투자 에이전트 운영 지침
|
# 은퇴자산포트폴리오 투자 에이전트 운영 지침
|
||||||
|
|
||||||
|
## QuantEngine 운영 설정 권위
|
||||||
|
- `ConnectionStrings__DefaultConnection`은 운영 설정에서 관리한다.
|
||||||
|
- 저장소 코드, DbUp migration, CI artifact는 운영 계정 비밀번호를 생성하거나 덮어쓰지 않는다. 단, 명시된 운영 설정 복원 작업은 예외로 한다.
|
||||||
|
- 배포/검증 하네스는 설정값을 읽기만 하며, 값 자체를 로그·증빙·커밋에 기록하지 않는다.
|
||||||
|
- 설정 변경은 애플리케이션 배포와 분리된 운영 설정 변경으로 취급한다. 설정 복원 시에는 Git 이력의 마지막 권위값만 사용한다.
|
||||||
|
|
||||||
## 0. 최우선 원칙
|
## 0. 최우선 원칙
|
||||||
- 이 파일은 운영 인덱스다. 상세 규칙은 `governance/rules/*.yaml`와 `spec/*.yaml`를 우선한다.
|
- 이 파일은 운영 인덱스다. 상세 규칙은 `governance/rules/*.yaml`와 `spec/*.yaml`를 우선한다.
|
||||||
- 가격, 수량, TP/SL, 점수는 오직 `spec/13_formula_registry.yaml`와 하네스 산출값만 사용한다.
|
- 가격, 수량, TP/SL, 점수는 오직 `spec/13_formula_registry.yaml`와 하네스 산출값만 사용한다.
|
||||||
|
|||||||
@@ -688,3 +688,6 @@ See `docs/GITEA_ACTIONS_API_GUIDE.md` for complete API reference.
|
|||||||
- **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible
|
- **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible
|
||||||
- **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11
|
- **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11
|
||||||
- **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above)
|
- **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above)
|
||||||
|
- **DBML Schema Sync (2026-07-12)**: DbUp 마이그레이션(`src/dotnet/QuantEngine.Infrastructure/Migrations/V*.sql`)으로 관리되는 모든 테이블은 **반드시 `docs/db/quantengine.dbml`에도 동기화**되어야 하며, 개발 시 스키마 참조는 이 DBML 파일을 기준으로 한다. 새 마이그레이션 추가 시 같은 커밋에서 DBML 갱신 필수.
|
||||||
|
- **Diagrams**: 상태전이/플로우차트/시퀀스 다이어그램은 Mermaid로 `docs/diagrams/`에 작성해 코딩 참조로 활용 (수집 파이프라인: `docs/diagrams/collection-pipeline.md`)
|
||||||
|
- **WBS Evidence Gate (2026-07-12)**: 퀀트 엔진 로드맵/WBS는 `spec/60_quant_engine_wbs.yaml`(기계 판정)로 관리. 작업 완료는 `npm run verify:task -- <TASK_ID>` 게이트 PASS로만 인정 (BE=PG쿼리/로그/JSON, FE=Playwright+스크린샷). 전체 게이트: `npm run verify:wbs`
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Report Guide (보고서 지침)
|
||||||
|
|
||||||
|
본 문서는 은퇴자산 포트폴리오 투자 에이전트의 보고 및 작업 완료 기준을 정의합니다.
|
||||||
|
|
||||||
|
## 기본 완료 조건 (Default Completion Harness)
|
||||||
|
모든 작업은 아래의 4가지 요소가 모두 충족되어 검증을 통과해야 완료로 판정합니다.
|
||||||
|
|
||||||
|
1. **YAML 계약/공식**: 계약, 공식 및 거버넌스 파일(`yaml`)의 원본 권위가 변경 사항에 맞게 최신화되어야 합니다.
|
||||||
|
2. **코드 구현**: `code` 구현이 `src/` 또는 `tools/`에 명확히 반영되어야 합니다.
|
||||||
|
3. **데이터 실체**: 수집 및 계산 결과가 담긴 데이터 실체(`data artifact` 또는 `data/artifact`)가 디렉토리에 정상적으로 생성되고 확인되어야 합니다.
|
||||||
|
4. **검증 증빙**: 재현 가능한 테스트 실행 및 검증 명령의 결과 파일 또는 터미널 출력이 `validation evidence`(`검증 증빙`)로 기록되어야 합니다.
|
||||||
|
|
||||||
|
이러한 완료 프로세스는 `completion harness`를 통해 엄격하게 통제됩니다.
|
||||||
@@ -2271,3 +2271,17 @@ python tools/validate_snapshot_admin_web_v1.py
|
|||||||
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
|
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
|
||||||
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
|
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
|
||||||
> 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
|
> 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 차세대 퀀트 엔진 로드맵/WBS 포인터 (2026-07-12)
|
||||||
|
|
||||||
|
이후의 퀀트 엔진 진화 로드맵(M0–M5: 실증 하네스 → 수집 배선 → 시계열 저장소 →
|
||||||
|
실데이터 팩터 → 백테스팅 → 포트폴리오/레짐)과 상세 WBS는 **기계 판정 YAML**로 관리한다:
|
||||||
|
|
||||||
|
- **스펙(단일 진실 원천)**: `spec/60_quant_engine_wbs.yaml` (formula_id: `QUANT_ENGINE_WBS_V1`)
|
||||||
|
- **단일 작업 검증**: `python tools/verify_wbs_task_v1.py --task <TASK_ID>` → `Temp/evidence/<TASK_ID>/verdict.json`
|
||||||
|
- **전체 WBS 게이트**: `python tools/validate_quant_engine_wbs_v1.py` → `Temp/quant_engine_wbs_v1.json`
|
||||||
|
|
||||||
|
완료 판정 원칙: 작업은 게이트 실행(PASS)으로만 `DONE` 이 될 수 있다.
|
||||||
|
BE = PostgreSQL 쿼리 + Serilog 로그 패턴 + JSON 아티팩트, FE = Playwright(DOM assert + API 기대값 대조 + 스크린샷).
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// QuantEngine Database Schema (DBML)
|
||||||
|
// DbUp 마이그레이션(V1~V5)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
|
||||||
|
// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화)
|
||||||
|
//
|
||||||
|
// 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성
|
||||||
|
// (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
Project quantengine {
|
||||||
|
database_type: 'PostgreSQL'
|
||||||
|
Note: '''
|
||||||
|
QuantEngine v0.1 데이터베이스 스키마.
|
||||||
|
세 개 스키마로 구성:
|
||||||
|
- quantengine: 핵심 KIS API 토큰, 사용자 계정, 수집 파이프라인 데이터
|
||||||
|
- engine_history: 팩터 계산 이력, 시장 데이터 이력, 의사결정 이력
|
||||||
|
- (생략) hangfire: Hangfire 백그라운드 잡 관리 (auto-created)
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Schema: quantengine (V1 + V2)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
TableGroup "quantengine" {
|
||||||
|
kis_tokens
|
||||||
|
workspace_account
|
||||||
|
workspace_session
|
||||||
|
collection_runs
|
||||||
|
collection_snapshots
|
||||||
|
collection_source_errors
|
||||||
|
settings
|
||||||
|
account_snapshot
|
||||||
|
workspace_meta
|
||||||
|
workspace_change_log
|
||||||
|
workspace_approval_v2
|
||||||
|
workspace_lock
|
||||||
|
kis_collection_runs
|
||||||
|
kis_collection_snapshots
|
||||||
|
kis_collection_errors
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.kis_tokens {
|
||||||
|
account TEXT [pk, note: "KIS 계정 모드 (real/mock)"]
|
||||||
|
access_token TEXT [not null, note: "KIS 토큰"]
|
||||||
|
expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"]
|
||||||
|
updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
Note: "KIS Open API 인증 토큰 캐시"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.workspace_account {
|
||||||
|
ordinal INT [not null, note: "순서 인덱스"]
|
||||||
|
username TEXT [pk, note: "로그인 ID"]
|
||||||
|
password_hash TEXT [not null, note: "BCrypt 또는 SHA-256 해시 (자동 마이그레이션 가능)"]
|
||||||
|
role TEXT [not null, default: "'Admin'", note: "역할 (Admin)"]
|
||||||
|
is_active TEXT [not null, default: "'true'", note: "활성 상태 (true/false)"]
|
||||||
|
created_at TEXT [not null, note: "생성 시각 (ISO 8601)"]
|
||||||
|
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(is_active, username) [name: "idx_workspace_account_active"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "Admin UI 사용자 계정"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.workspace_session {
|
||||||
|
session_token_hash TEXT [pk, note: "세션 토큰 해시"]
|
||||||
|
username TEXT [not null, note: "사용자명"]
|
||||||
|
role TEXT [not null, default: "'Admin'", note: "역할"]
|
||||||
|
created_at TEXT [not null, note: "세션 생성 시각 (ISO 8601)"]
|
||||||
|
expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"]
|
||||||
|
revoked_at TEXT [note: "취소 시각 (ISO 8601), NULL이면 활성"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(username, expires_at) [name: "idx_workspace_session_username"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "세션 관리 (쿠키 기반 인증)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.collection_runs {
|
||||||
|
run_id TEXT [pk, note: "수집 실행 ID (예: api-20260712-120000)"]
|
||||||
|
collector_name TEXT [not null, note: "수집기 이름"]
|
||||||
|
started_at TEXT [not null, note: "시작 시각 (ISO 8601)"]
|
||||||
|
finished_at TEXT [note: "종료 시각 (ISO 8601)"]
|
||||||
|
status TEXT [not null, note: "상태 (RUNNING/COMPLETED/FAILED)"]
|
||||||
|
input_source TEXT [note: "입력 소스 경로"]
|
||||||
|
output_json_path TEXT [note: "출력 JSON 파일 경로"]
|
||||||
|
output_db_path TEXT [note: "출력 DB 경로"]
|
||||||
|
notes TEXT [note: "메모"]
|
||||||
|
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
|
||||||
|
|
||||||
|
Note: "데이터 수집 실행 기록 (레거시, V2의 kis_collection_runs 참조)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.collection_snapshots {
|
||||||
|
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||||
|
dataset_name TEXT [not null, note: "데이터셋명"]
|
||||||
|
ticker TEXT [not null, note: "종목코드 (예: 005930)"]
|
||||||
|
name TEXT [note: "종목명"]
|
||||||
|
sector TEXT [note: "업종"]
|
||||||
|
as_of_date TEXT [note: "기준 일자"]
|
||||||
|
source_priority TEXT [note: "소스 우선순위"]
|
||||||
|
source_status TEXT [note: "소스 상태"]
|
||||||
|
payload_json TEXT [not null, note: "정규화된 데이터 (JSON)"]
|
||||||
|
provenance_json TEXT [not null, note: "출처 정보 (JSON)"]
|
||||||
|
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(run_id, dataset_name, ticker) [pk]
|
||||||
|
(ticker, created_at) [name: "idx_collection_snapshots_ticker_time"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "수집 스냅샷 (레거시, V2의 kis_collection_snapshots 참조)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.collection_source_errors {
|
||||||
|
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||||
|
ticker TEXT [note: "종목코드"]
|
||||||
|
source_name TEXT [not null, note: "소스명"]
|
||||||
|
error_kind TEXT [not null, note: "에러 타입"]
|
||||||
|
error_message TEXT [not null, note: "에러 메시지"]
|
||||||
|
payload_json TEXT [note: "에러 상세 (JSON)"]
|
||||||
|
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(run_id, source_name) [name: "idx_collection_source_errors_run"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "수집 중 발생한 에러 기록 (레거시)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.settings {
|
||||||
|
ordinal INT [not null, note: "순서 인덱스"]
|
||||||
|
key TEXT [pk, note: "설정 키"]
|
||||||
|
value_json TEXT [not null, note: "값 (JSON)"]
|
||||||
|
note TEXT [not null, default: "''", note: "설명"]
|
||||||
|
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
Note: "애플리케이션 설정 저장소"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.account_snapshot {
|
||||||
|
ordinal INT [not null, note: "순서 인덱스"]
|
||||||
|
row_json TEXT [not null, note: "계정 데이터 (JSON)"]
|
||||||
|
captured_at TEXT [not null, default: "''", note: "캡처 시각 (ISO 8601)"]
|
||||||
|
account TEXT [not null, default: "''", note: "계정"]
|
||||||
|
account_type TEXT [not null, default: "''", note: "계정 타입"]
|
||||||
|
ticker TEXT [not null, default: "''", note: "종목코드"]
|
||||||
|
name TEXT [not null, default: "''", note: "이름"]
|
||||||
|
parse_status TEXT [not null, default: "''", note: "파싱 상태"]
|
||||||
|
user_confirmed TEXT [not null, default: "''", note: "사용자 확인 여부"]
|
||||||
|
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(captured_at) [name: "idx_account_snapshot_captured_at"]
|
||||||
|
(ticker) [name: "idx_account_snapshot_ticker"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "계정 스냅샷 저장소"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.workspace_meta {
|
||||||
|
key TEXT [pk, note: "메타 키"]
|
||||||
|
value_json TEXT [not null, note: "값 (JSON)"]
|
||||||
|
|
||||||
|
Note: "워크스페이스 메타데이터"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.workspace_change_log {
|
||||||
|
id SERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
domain TEXT [not null, note: "도메인"]
|
||||||
|
action TEXT [not null, note: "액션 (create/update/delete)"]
|
||||||
|
target_ref TEXT [not null, default: "''", note: "대상 참조"]
|
||||||
|
actor TEXT [not null, default: "'system'", note: "액터 (사용자/시스템)"]
|
||||||
|
note TEXT [not null, default: "''", note: "메모"]
|
||||||
|
before_json TEXT [not null, default: "'null'", note: "변경 전 값 (JSON)"]
|
||||||
|
after_json TEXT [not null, default: "'null'", note: "변경 후 값 (JSON)"]
|
||||||
|
created_at TEXT [not null, note: "기록 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
Note: "변경 로그"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.workspace_approval_v2 {
|
||||||
|
domain TEXT [not null, note: "도메인"]
|
||||||
|
target_ref TEXT [not null, default: "'*'", note: "대상 참조"]
|
||||||
|
status TEXT [not null, note: "승인 상태"]
|
||||||
|
approved_by TEXT [not null, default: "''", note: "승인자"]
|
||||||
|
approved_at TEXT [not null, default: "''", note: "승인 시각 (ISO 8601)"]
|
||||||
|
note TEXT [not null, default: "''", note: "메모"]
|
||||||
|
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(domain, target_ref) [pk]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "승인 워크플로우"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.workspace_lock {
|
||||||
|
domain TEXT [not null, note: "도메인"]
|
||||||
|
target_ref TEXT [not null, default: "''", note: "대상 참조"]
|
||||||
|
locked_by TEXT [not null, default: "''", note: "잠금 사용자"]
|
||||||
|
reason TEXT [not null, default: "''", note: "잠금 사유"]
|
||||||
|
locked_at TEXT [not null, note: "잠금 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(domain, target_ref) [pk]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "동시성 제어용 잠금"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// V2: KIS 수집 파이프라인 (kis_collection_*)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
Table quantengine.kis_collection_runs {
|
||||||
|
run_id TEXT [pk, note: "수집 실행 ID"]
|
||||||
|
status TEXT [not null, note: "상태: RUNNING / COMPLETED / COMPLETED_WITH_ERRORS / FAILED"]
|
||||||
|
started_at TEXT [not null, note: "시작 시각 (ISO 8601 KST)"]
|
||||||
|
finished_at TEXT [note: "종료 시각 (ISO 8601 KST)"]
|
||||||
|
total_snapshots INTEGER [note: "성공한 스냅샷 수"]
|
||||||
|
total_errors INTEGER [note: "발생한 에러 수"]
|
||||||
|
updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(started_at) [name: "idx_kis_runs_started_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "KIS API 수집 실행 기록"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.kis_collection_snapshots {
|
||||||
|
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||||
|
dataset_name TEXT [note: "데이터셋명 (예: data_feed)"]
|
||||||
|
ticker TEXT [not null, note: "종목코드 (예: 005930)"]
|
||||||
|
source_name TEXT [not null, note: "데이터 소스 (kis_open_api 등)"]
|
||||||
|
payload_json TEXT [not null, note: "정규화된 수집 데이터 (JSON)"]
|
||||||
|
captured_at TEXT [not null, note: "캡처 시각 (ISO 8601 KST)"]
|
||||||
|
created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(run_id, ticker, source_name) [pk]
|
||||||
|
(ticker) [name: "idx_kis_snapshots_ticker"]
|
||||||
|
(captured_at) [name: "idx_kis_snapshots_captured_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "KIS API 수집 스냅샷 (시계열 데이터)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.kis_collection_errors {
|
||||||
|
id SERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||||
|
source_name TEXT [not null, note: "데이터 소스"]
|
||||||
|
error_kind TEXT [not null, note: "에러 타입 (예: HttpRequestException)"]
|
||||||
|
error_message TEXT [note: "에러 메시지"]
|
||||||
|
ticker TEXT [note: "종목코드 (해당하면)"]
|
||||||
|
created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(run_id) [name: "idx_kis_errors_run_id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "KIS API 수집 중 발생한 에러"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Schema: engine_history (V3)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
TableGroup "engine_history" {
|
||||||
|
market_raw_history
|
||||||
|
factor_version_history
|
||||||
|
factor_output_history
|
||||||
|
decision_result_history
|
||||||
|
market_vs_engine_gap_history
|
||||||
|
source_observation
|
||||||
|
factor_definition
|
||||||
|
factor_observation
|
||||||
|
decision_event
|
||||||
|
decision_factor_evidence
|
||||||
|
outcome_evaluation
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.market_raw_history {
|
||||||
|
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
source_id TEXT [not null, note: "소스 ID"]
|
||||||
|
observed_at TEXT [not null, note: "관측 시각 (ISO 8601)"]
|
||||||
|
source_name TEXT [not null, note: "소스명 (kis_open_api 등)"]
|
||||||
|
instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"]
|
||||||
|
field_name TEXT [not null, note: "필드명 (현재가, 종가 등)"]
|
||||||
|
field_value TEXT [not null, note: "필드값 (문자열)"]
|
||||||
|
unit TEXT [not null, note: "단위 (원, % 등)"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(created_at) [name: "idx_market_raw_history_created_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "시장 데이터 원본 이력 (정규화 전)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.factor_version_history {
|
||||||
|
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
factor_id TEXT [not null, note: "팩터 ID (예: momentum_ss001)"]
|
||||||
|
factor_version TEXT [not null, note: "팩터 버전 (예: v1.0.0)"]
|
||||||
|
effective_from TEXT [not null, note: "유효 시작 일자 (YYYYMMDD)"]
|
||||||
|
effective_to TEXT [not null, note: "유효 종료 일자 (YYYYMMDD)"]
|
||||||
|
formula_id TEXT [not null, note: "계산식 ID"]
|
||||||
|
source_version TEXT [not null, note: "소스 버전"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(created_at) [name: "idx_factor_version_history_created_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "팩터 버전 관리 이력"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.factor_output_history {
|
||||||
|
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
factor_output_id TEXT [not null, note: "팩터 출력 ID"]
|
||||||
|
observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"]
|
||||||
|
factor_id TEXT [not null, note: "팩터 ID"]
|
||||||
|
factor_version TEXT [not null, note: "팩터 버전"]
|
||||||
|
output_value TEXT [not null, note: "출력값 (문자열)"]
|
||||||
|
output_gate TEXT [not null, note: "게이트 (PASS/FAIL/WARN)"]
|
||||||
|
source_version TEXT [not null, note: "소스 버전"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(created_at) [name: "idx_factor_output_history_created_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "팩터 계산 결과 이력"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.decision_result_history {
|
||||||
|
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
decision_id TEXT [not null, note: "의사결정 ID"]
|
||||||
|
decided_at TEXT [not null, note: "의사결정 일자 (YYYYMMDD)"]
|
||||||
|
instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"]
|
||||||
|
action TEXT [not null, note: "액션 (BUY/SELL/HOLD)"]
|
||||||
|
gate TEXT [not null, note: "게이트 (PASS/FAIL)"]
|
||||||
|
score TEXT [not null, note: "스코어 (문자열)"]
|
||||||
|
source_version TEXT [not null, note: "소스 버전"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(created_at) [name: "idx_decision_result_history_created_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "의사결정 결과 이력"
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.market_vs_engine_gap_history {
|
||||||
|
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||||
|
gap_id TEXT [not null, note: "갭 ID"]
|
||||||
|
observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"]
|
||||||
|
instrument_id TEXT [not null, note: "상품 ID"]
|
||||||
|
metric_name TEXT [not null, note: "지표명"]
|
||||||
|
market_value TEXT [not null, note: "시장값"]
|
||||||
|
engine_value TEXT [not null, note: "엔진값"]
|
||||||
|
gap_value TEXT [not null, note: "갭값 (절대값)"]
|
||||||
|
gap_pct TEXT [not null, note: "갭 백분율 (%)"]
|
||||||
|
source_version TEXT [not null, note: "소스 버전"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(created_at) [name: "idx_market_vs_engine_gap_history_created_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: "시장 데이터 vs 엔진 계산 갭 분석 이력"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Schema: engine_history (V5 normalized learning history)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
Table quantengine.price_history_daily {
|
||||||
|
ticker TEXT [not null]
|
||||||
|
trade_date DATE [not null]
|
||||||
|
open NUMERIC [not null]
|
||||||
|
high NUMERIC [not null]
|
||||||
|
low NUMERIC [not null]
|
||||||
|
close NUMERIC [not null]
|
||||||
|
volume BIGINT [not null]
|
||||||
|
source TEXT [not null]
|
||||||
|
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(ticker, trade_date) [pk]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table quantengine.macro_history_daily {
|
||||||
|
symbol TEXT [not null]
|
||||||
|
trade_date DATE [not null]
|
||||||
|
value NUMERIC [not null]
|
||||||
|
source TEXT [not null]
|
||||||
|
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(symbol, trade_date) [pk]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.source_observation {
|
||||||
|
observation_id UUID [pk]
|
||||||
|
observed_at TIMESTAMPTZ [not null]
|
||||||
|
instrument_id TEXT [not null]
|
||||||
|
source_name TEXT [not null]
|
||||||
|
source_version TEXT [not null]
|
||||||
|
payload JSONB [not null]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.factor_definition {
|
||||||
|
factor_id TEXT [not null]
|
||||||
|
factor_version TEXT [not null]
|
||||||
|
formula_id TEXT [not null]
|
||||||
|
effective_from TIMESTAMPTZ [not null]
|
||||||
|
effective_to TIMESTAMPTZ
|
||||||
|
definition JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(factor_id, factor_version) [pk]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.factor_observation {
|
||||||
|
factor_observation_id UUID [pk]
|
||||||
|
observation_id UUID [not null]
|
||||||
|
factor_id TEXT [not null]
|
||||||
|
factor_version TEXT [not null]
|
||||||
|
observed_at TIMESTAMPTZ [not null]
|
||||||
|
numeric_value NUMERIC
|
||||||
|
text_value TEXT
|
||||||
|
gate TEXT [not null]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.decision_event {
|
||||||
|
decision_id UUID [pk]
|
||||||
|
decision_key TEXT [not null, unique]
|
||||||
|
decided_at TIMESTAMPTZ [not null]
|
||||||
|
instrument_id TEXT [not null]
|
||||||
|
action TEXT [not null]
|
||||||
|
gate TEXT [not null]
|
||||||
|
score NUMERIC
|
||||||
|
source_version TEXT [not null]
|
||||||
|
trace JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
created_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.decision_factor_evidence {
|
||||||
|
decision_id UUID [not null]
|
||||||
|
factor_observation_id UUID [not null]
|
||||||
|
role TEXT [not null]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(decision_id, factor_observation_id) [pk]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table engine_history.outcome_evaluation {
|
||||||
|
evaluation_id UUID [pk]
|
||||||
|
decision_id UUID [not null]
|
||||||
|
horizon_days INT [not null]
|
||||||
|
evaluated_at TIMESTAMPTZ [not null]
|
||||||
|
realized_return NUMERIC
|
||||||
|
benchmark_return NUMERIC
|
||||||
|
excess_return NUMERIC
|
||||||
|
outcome_class TEXT [not null]
|
||||||
|
evaluation_gate TEXT [not null]
|
||||||
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(decision_id, horizon_days) [unique]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Relationships (Logical, not enforced as FKs in DDL)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
Ref: quantengine.kis_collection_snapshots.run_id > quantengine.kis_collection_runs.run_id {
|
||||||
|
// logical relationship: snapshots belong to a run
|
||||||
|
}
|
||||||
|
|
||||||
|
Ref: quantengine.kis_collection_errors.run_id > quantengine.kis_collection_runs.run_id {
|
||||||
|
// logical relationship: errors belong to a run
|
||||||
|
}
|
||||||
|
|
||||||
|
Ref: quantengine.workspace_session.username > quantengine.workspace_account.username {
|
||||||
|
// logical relationship: session belongs to a user
|
||||||
|
}
|
||||||
|
|
||||||
|
Ref: engine_history.factor_observation.observation_id > engine_history.source_observation.observation_id
|
||||||
|
Ref: engine_history.factor_observation.(factor_id, factor_version) > engine_history.factor_definition.(factor_id, factor_version)
|
||||||
|
Ref: engine_history.decision_factor_evidence.decision_id > engine_history.decision_event.decision_id
|
||||||
|
Ref: engine_history.decision_factor_evidence.factor_observation_id > engine_history.factor_observation.factor_observation_id
|
||||||
|
Ref: engine_history.outcome_evaluation.decision_id > engine_history.decision_event.decision_id
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# QuantEngine 수집 파이프라인 (KIS API)
|
||||||
|
|
||||||
|
## 1. 수집 실행 상태 전이도 (State Diagram)
|
||||||
|
|
||||||
|
KIS 데이터 수집 실행(kis_collection_runs)의 상태 흐름. 상태값은 KisDataCollectionOrchestrator 에서 정의:
|
||||||
|
- `RUNNING`: 수집 진행 중
|
||||||
|
- `COMPLETED`: 모든 스냅샷 수집 완료 (에러 없음, `total_errors == 0`)
|
||||||
|
- `COMPLETED_WITH_ERRORS`: 부분 수집 완료 (에러 발생, `total_errors > 0`이지만 일부 성공)
|
||||||
|
- `FAILED`: 전체 실패 (예외 발생, 데이터 미적재)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> RUNNING: 수집 시작<br/>(RunCollectionAsync)
|
||||||
|
RUNNING --> COMPLETED: 완료 & error_count==0
|
||||||
|
RUNNING --> COMPLETED_WITH_ERRORS: 완료 & error_count>0
|
||||||
|
RUNNING --> FAILED: 예외 발생
|
||||||
|
COMPLETED --> [*]
|
||||||
|
COMPLETED_WITH_ERRORS --> [*]
|
||||||
|
FAILED --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 전이 조건** (KisDataCollectionOrchestrator.cs 라인 104-105):
|
||||||
|
- `error_count == 0` → `COMPLETED`
|
||||||
|
- `error_count > 0` → `COMPLETED_WITH_ERRORS`
|
||||||
|
- 예외(Exception) → `FAILED`
|
||||||
|
|
||||||
|
**성공 기준** (CLAUDE.md "Collection Run Success Criteria"):
|
||||||
|
- Success: `status == "COMPLETED"` (NOT failed)
|
||||||
|
- Partial Success: `status == "COMPLETED"` + `total_snapshots > 0` + `total_errors > 0`
|
||||||
|
- Failure: `status == "FAILED"` OR `total_snapshots == 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 수집 파이프라인 흐름도 (Flowchart)
|
||||||
|
|
||||||
|
KIS API 데이터 수집의 전체 흐름. 두 개의 트리거:
|
||||||
|
1. **Hangfire 정기 작업**: 매일 09:00 에 자동 실행
|
||||||
|
2. **API 수동 트리거**: POST /api/collection/run (쿠키 기반 인증)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Hangfire daily-collection<br/>(09:00 KST)"]
|
||||||
|
B["POST /api/collection/run<br/>(Cookie Auth)"]
|
||||||
|
|
||||||
|
A --> C["IServiceScopeFactory.CreateScope<br/>(resolve ICollectionOrchestrator)"]
|
||||||
|
B --> C
|
||||||
|
|
||||||
|
C --> D["KisDataCollectionOrchestrator.RunCollectionAsync<br/>(tickers: [005930, 000660, ...])"]
|
||||||
|
|
||||||
|
D --> E["Per-ticker 루프"]
|
||||||
|
E --> F["KisApiPriceSource.GetPriceDataAsync<br/>(ticker, account)"]
|
||||||
|
F --> G["PriceDataNormalizer.NormalizeCollectionRow<br/>(seedRow, kisResult)"]
|
||||||
|
G --> H["CollectionRepository.SaveSnapshot<br/>(kis_collection_snapshots)"]
|
||||||
|
G --> I["CollectionRepository.SaveError<br/>(kis_collection_errors, on exception)"]
|
||||||
|
|
||||||
|
H --> J{루프 끝?}
|
||||||
|
I --> J
|
||||||
|
J -->|Yes| K["CollectionRepository.SaveRun<br/>(kis_collection_runs)"]
|
||||||
|
J -->|No| E
|
||||||
|
|
||||||
|
K --> L["파일 출력:<br/>Temp/kis_dotnet_collection_v1.json"]
|
||||||
|
L --> M["Serilog 로그:<br/>src/dotnet/.../logs/"]
|
||||||
|
|
||||||
|
M --> N["Admin UI: /Admin/Collection<br/>(CollectionRepository 읽기)"]
|
||||||
|
N --> O["대시보드 표시:<br/>상태, 스냅샷 수, 에러"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**데이터 흐름**:
|
||||||
|
1. **입력**: Hangfire 스케줄 or API 수동 요청
|
||||||
|
2. **오케스트레이션**: ICollectionOrchestrator 스코프 생성
|
||||||
|
3. **수집**: KIS Open API 호출 → PriceDataNormalizer → DB 저장
|
||||||
|
4. **출력**:
|
||||||
|
- kis_collection_runs: 실행 메타데이터 (run_id, status, total_snapshots, total_errors)
|
||||||
|
- kis_collection_snapshots: 종목별 가격 데이터 (JSON payload)
|
||||||
|
- kis_collection_errors: 에러 기록
|
||||||
|
- Temp/kis_dotnet_collection_v1.json: 수집 결과 요약 (formula_id, gate, run_id, summary)
|
||||||
|
- Serilog 로그: 런타임 로그 (src/dotnet/QuantEngine.Web/logs/)
|
||||||
|
5. **표시**: Admin UI에서 CollectionRepository API 호출 → kis_collection_* 읽기 → Dashboard 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. WBS 증거 검증 시퀀스도 (Sequence Diagram)
|
||||||
|
|
||||||
|
작업 완료 증거를 자동 검증하는 파이프라인. 도구: `verify_wbs_task_v1.py` (증거 수집) + `validate_quant_engine_wbs_v1.py` (CI에서 재검증).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
Developer->>verify_wbs_task_v1.py: python verify_wbs_task_v1.py --task QE-M1-01<br/>(또는 --run-commands)
|
||||||
|
verify_wbs_task_v1.py->>+spec/60_quant_engine_wbs.yaml: load spec
|
||||||
|
spec/60_quant_engine_wbs.yaml-->>-verify_wbs_task_v1.py: meta + tasks[QE-M1-01]
|
||||||
|
|
||||||
|
Note over verify_wbs_task_v1.py: evidence_checks 선언형 해석
|
||||||
|
|
||||||
|
alt pg_query 체크
|
||||||
|
verify_wbs_task_v1.py->>+PostgreSQL: SELECT ... (WHERE 절)
|
||||||
|
PostgreSQL-->>-verify_wbs_task_v1.py: 스칼라 결과 또는 행
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: expect{min,max,equals} 비교
|
||||||
|
end
|
||||||
|
|
||||||
|
alt log_pattern 체크
|
||||||
|
verify_wbs_task_v1.py->>+src/dotnet/.../logs/: file_glob 매칭
|
||||||
|
src/dotnet/.../logs/-->>-verify_wbs_task_v1.py: 로그 라인
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 정규식 패턴 검사<br/>(min_matches, max_age_hours)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt json_gate 체크
|
||||||
|
verify_wbs_task_v1.py->>+Temp/kis_dotnet_collection_v1.json: read JSON
|
||||||
|
Temp/kis_dotnet_collection_v1.json-->>-verify_wbs_task_v1.py: payload
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 점 표기 경로(dot notation)<br/>+ 값 비교 (>=N 지원)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt file_exists 체크
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: paths[] 존재 확인<br/>(min_bytes 검증)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt playwright_report 체크
|
||||||
|
verify_wbs_task_v1.py->>+tests/e2e/playwright-report.json: read report
|
||||||
|
tests/e2e/playwright-report.json-->>-verify_wbs_task_v1.py: suites[].specs[]
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: spec_file 매칭<br/>(passed_min, failed)
|
||||||
|
end
|
||||||
|
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 모든 체크 결과 종합<br/>(gate = ALL PASS? → PASS : FAIL)
|
||||||
|
|
||||||
|
verify_wbs_task_v1.py->>+Temp/evidence/QE-M1-01/: mkdir
|
||||||
|
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/verdict.json: write verdict<br/>(task_id, gate, checks[])
|
||||||
|
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/: save raw evidence<br/>(pg_query_n.json, log_excerpt.txt, ...)
|
||||||
|
|
||||||
|
verify_wbs_task_v1.py->>+runtime/lineage_events.jsonl: append event<br/>(node_id, gate, timestamp)
|
||||||
|
|
||||||
|
Developer<<--verify_wbs_task_v1.py: exit 0 (gate=PASS)<br/>or exit 1 (gate=FAIL)
|
||||||
|
|
||||||
|
Note over Developer: 선택: --run-commands 플래그<br/>verification_commands[] 실행
|
||||||
|
|
||||||
|
Developer->>+validate_quant_engine_wbs_v1.py: (CI) python validate_quant_engine_wbs_v1.py
|
||||||
|
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: spec load
|
||||||
|
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: tasks[status==DONE] 필터
|
||||||
|
validate_quant_engine_wbs_v1.py->>+Temp/evidence/*/verdict.json: load all verdicts
|
||||||
|
Temp/evidence/*/verdict.json-->>-validate_quant_engine_wbs_v1.py: gate 값
|
||||||
|
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: gate=FAIL? → CI FAIL
|
||||||
|
validate_quant_engine_wbs_v1.py->>+Temp/quant_engine_wbs_v1.json: write summary
|
||||||
|
Developer<<--validate_quant_engine_wbs_v1.py: exit 0 (모두 PASS)<br/>or exit 1 (일부 FAIL)
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 프로세스 상세**:
|
||||||
|
|
||||||
|
| 단계 | 역할 | 산출물 |
|
||||||
|
|------|------|--------|
|
||||||
|
| **1. 스펙 로드** | verify_wbs_task_v1.py | spec/60_quant_engine_wbs.yaml |
|
||||||
|
| **2. 증거 체크 실행** | 선언형 evidence_checks[] | pg_query / log_pattern / json_gate / file_exists / playwright_report |
|
||||||
|
| **3. 게이트 결정** | 모든 체크 PASS? | gate = PASS or FAIL |
|
||||||
|
| **4. 증거 저장** | Temp/evidence/<TASK_ID>/ | verdict.json + 원시 증거 |
|
||||||
|
| **5. 계보 로깅** | runtime/lineage_events.jsonl | node_id, gate, timestamp |
|
||||||
|
| **6. CI 재검증** | validate_quant_engine_wbs_v1.py | status=DONE 작업만 재검증 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **선언형 검증**: 체크 로직을 YAML에 기술 (하드코딩 최소화)
|
||||||
|
- **원시 증거 보존**: 각 체크의 상세 결과를 JSON/텍스트로 저장
|
||||||
|
- **완료 주장 차단**: "완료했다"는 수동 선언 불가 → verdict.json gate=PASS만 인정
|
||||||
|
- **CI 편입**: validate_quant_engine_wbs_v1.py가 release DAG의 노드로 동작
|
||||||
|
- **멀티 트리거**: 단일 작업 검증 (--task) 또는 전체 검증 (CI)
|
||||||
|
|
||||||
|
**검증 체크 타입 참고** (spec/60_quant_engine_wbs.yaml "evidence_check_types"):
|
||||||
|
- **pg_query**: PostgreSQL 스칼라 결과 비교 (min/max/equals)
|
||||||
|
- **log_pattern**: 로그 파일 정규식 매칭 (min_matches, max_age_hours)
|
||||||
|
- **json_gate**: JSON 아티팩트 키-값 검사 (점 표기 경로, >=N 비교)
|
||||||
|
- **file_exists**: 파일 존재 + 크기 검증 (min_bytes)
|
||||||
|
- **playwright_report**: Playwright 리포트 테스트 결과 (passed_min, failed)
|
||||||
+11
-1
@@ -52,7 +52,17 @@
|
|||||||
"validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
|
"validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
|
||||||
"validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
|
"validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
|
||||||
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict",
|
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict",
|
||||||
"render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json"
|
"render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json",
|
||||||
|
"verify:task": "python tools/verify_wbs_task_v1.py --task",
|
||||||
|
"collect:remote-evidence": "python tools/collect_remote_wbs_evidence_v1.py",
|
||||||
|
"verify:wbs": "python tools/validate_quant_engine_wbs_v1.py",
|
||||||
|
"validate:normalized-learning-store": "python tools/validate_normalized_learning_store_v1.py",
|
||||||
|
"validate:dotnet-cutover": "python tools/validate_dotnet_postgresql_json_cutover_v1.py",
|
||||||
|
"validate:schema-model": "python tools/generate_schema_model_generation_evidence_v1.py && python tools/validate_schema_model_generation_v1.py",
|
||||||
|
"validate:runtime-settings": "python tools/validate_runtime_connection_settings_immutability_v1.py",
|
||||||
|
"validate:market-schema": "python tools/validate_market_time_series_schema_v1.py",
|
||||||
|
"test:e2e": "playwright test --project=chromium",
|
||||||
|
"test:evidence": "playwright test --project=evidence"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
|
testIgnore: '**/archive/**',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
@@ -14,7 +15,7 @@ export default defineConfig({
|
|||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'list',
|
reporter: [['list'], ['json', { outputFile: 'Temp/evidence/playwright-last-run.json' }]],
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
@@ -29,8 +30,14 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
|
testIgnore: ['**/archive/**', '**/evidence/**'],
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'evidence',
|
||||||
|
testDir: './tests/e2e/evidence',
|
||||||
|
use: { ...devices['Desktop Chrome'], screenshot: 'on', trace: 'on' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ quant_feed_contract:
|
|||||||
database_first_operating_model:
|
database_first_operating_model:
|
||||||
purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다."
|
purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다."
|
||||||
canonical_store:
|
canonical_store:
|
||||||
primary: "PostgreSQL"
|
primary: "PostgreSQL"
|
||||||
|
canonical_engine_history_migration: "src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql"
|
||||||
|
learning_projection: "engine_history.training_example_v1"
|
||||||
secondary: "SQLite transient cache only"
|
secondary: "SQLite transient cache only"
|
||||||
prohibited_operating_path:
|
prohibited_operating_path:
|
||||||
- "Excel workbook as operational source"
|
- "Excel workbook as operational source"
|
||||||
@@ -190,6 +192,8 @@ quant_feed_contract:
|
|||||||
policy:
|
policy:
|
||||||
- "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다."
|
- "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다."
|
||||||
- "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다."
|
- "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다."
|
||||||
|
- "원천 관측·factor·decision·outcome은 정규화된 PostgreSQL event store에 적재한다."
|
||||||
|
- "학습/캘리브레이션 입력은 training_example_v1 역정규화 view에서만 생성한다."
|
||||||
- "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다."
|
- "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다."
|
||||||
- "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다."
|
- "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다."
|
||||||
xlsx_analysis_protocol:
|
xlsx_analysis_protocol:
|
||||||
|
|||||||
@@ -2280,6 +2280,23 @@ dag:
|
|||||||
strict: false
|
strict: false
|
||||||
timeout_sec: 60
|
timeout_sec: 60
|
||||||
warn_only: true
|
warn_only: true
|
||||||
|
validate_quant_engine_wbs:
|
||||||
|
artifact_policy: keep
|
||||||
|
cache_key: validate_quant_engine_wbs_v1
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- tools/validate_quant_engine_wbs_v1.py
|
||||||
|
depends_on: []
|
||||||
|
id: validate_quant_engine_wbs
|
||||||
|
inputs:
|
||||||
|
- tools/validate_quant_engine_wbs_v1.py
|
||||||
|
- spec/60_quant_engine_wbs.yaml
|
||||||
|
note: 퀀트 엔진 WBS 증거 게이트 — status=DONE 작업은 Temp/evidence/<TASK_ID>/verdict.json
|
||||||
|
gate=PASS 가 있어야 한다 (완료 주장 금지, 게이트 실행으로만 DONE).
|
||||||
|
outputs:
|
||||||
|
- Temp/quant_engine_wbs_v1.json
|
||||||
|
strict: true
|
||||||
|
timeout_sec: 60
|
||||||
validate_specs:
|
validate_specs:
|
||||||
artifact_policy: keep
|
artifact_policy: keep
|
||||||
cache_key: validate_specs_v1
|
cache_key: validate_specs_v1
|
||||||
@@ -2327,6 +2344,7 @@ execution_order:
|
|||||||
- validate_metric_alias_collision
|
- validate_metric_alias_collision
|
||||||
- validate_packaged_refs
|
- validate_packaged_refs
|
||||||
- validate_property_invariants
|
- validate_property_invariants
|
||||||
|
- validate_quant_engine_wbs
|
||||||
- validate_renderer_no_calc
|
- validate_renderer_no_calc
|
||||||
- validate_runtime_source_whitelist
|
- validate_runtime_source_whitelist
|
||||||
- validate_sector_universe_monthly_refresh
|
- validate_sector_universe_monthly_refresh
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# QuantEngine 데이터 실증 기반 퀀트 엔진 로드맵 + WBS (기계 판정)
|
||||||
|
# =============================================================================
|
||||||
|
# formula_id: QUANT_ENGINE_WBS_V1
|
||||||
|
# 원칙: 모든 작업(task)은 "완료 주장"이 아니라 게이트 실행으로만 DONE 판정된다.
|
||||||
|
# - BE 실증: pg_query(PostgreSQL 쿼리) + log_pattern(Serilog 로그) + json_gate(아티팩트)
|
||||||
|
# - FE 실증: playwright_report(스펙 PASS) + file_exists(스크린샷)
|
||||||
|
# 실행:
|
||||||
|
# 단일 작업 검증: python tools/verify_wbs_task_v1.py --task <TASK_ID>
|
||||||
|
# → Temp/evidence/<TASK_ID>/verdict.json + 원시 증거 보존
|
||||||
|
# 전체 WBS 게이트: python tools/validate_quant_engine_wbs_v1.py
|
||||||
|
# → Temp/quant_engine_wbs_v1.json (status=DONE 작업의 증거 재검증)
|
||||||
|
# 관례: spec/16_data_gaps_roadmap.yaml 의 success_criteria 구조
|
||||||
|
# (expected_success_value / evidence_artifacts / verification_commands) 준수.
|
||||||
|
# 검증 로직만 하드코딩 → evidence_checks 선언형으로 일반화.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
meta:
|
||||||
|
formula_id: QUANT_ENGINE_WBS_V1
|
||||||
|
version: 1
|
||||||
|
created: "2026-07-12"
|
||||||
|
authority: "governance/authority_matrix.yaml"
|
||||||
|
validator: tools/validate_quant_engine_wbs_v1.py
|
||||||
|
task_verifier: tools/verify_wbs_task_v1.py
|
||||||
|
remote_evidence_collector: tools/collect_remote_wbs_evidence_v1.py
|
||||||
|
evidence_root: Temp/evidence
|
||||||
|
status_values: [PENDING, IN_PROGRESS, DONE] # DONE = 해당 verdict.json gate=PASS 필수
|
||||||
|
db_connection:
|
||||||
|
# 검증기의 PostgreSQL 접속 순서:
|
||||||
|
# 1) env QE_WBS_PG_DSN (psycopg DSN)
|
||||||
|
# 2) env ConnectionStrings__DefaultConnection (.NET 형식 → 자동 변환)
|
||||||
|
# 3) src/dotnet/QuantEngine.Web/appsettings.Development.json 의 ConnectionStrings.DefaultConnection
|
||||||
|
# (로컬은 SSH 터널 127.0.0.1:5432 전제 — CLAUDE.md "Local Development & Testing")
|
||||||
|
dotnet_appsettings: src/dotnet/QuantEngine.Web/appsettings.Development.json
|
||||||
|
remote_evidence:
|
||||||
|
collector: "python tools/collect_remote_wbs_evidence_v1.py --target <ssh-target>"
|
||||||
|
policy: "Collect journal and JSON artifacts only; never copy env files or passwords."
|
||||||
|
postgres: "Use QE_WBS_PG_DSN through an approved SSH tunnel; do not embed credentials in evidence."
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 검증 체크 타입 사전 (verify_wbs_task_v1.py 가 해석하는 선언형 vocabulary)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
evidence_check_types:
|
||||||
|
pg_query: "PostgreSQL 쿼리 1개 실행, 단일 스칼라 결과를 expect{min,max,equals}와 비교. 원시 결과를 pg_query_<n>.json 으로 보존"
|
||||||
|
log_pattern: "file_glob 로그 파일들에서 정규식 매칭. expect{min_matches, max_age_hours(파일 mtime 기준)}. 매칭 라인을 log_excerpt.txt 로 보존"
|
||||||
|
json_gate: "path 의 JSON 아티팩트에서 expect 의 키-값 검사 (점 표기 경로 지원, 값 '>=N' 비교 지원)"
|
||||||
|
file_exists: "paths 의 모든 파일 존재 (expect.min_bytes 선택)"
|
||||||
|
playwright_report: "Playwright JSON 리포트(report)에서 spec_file 의 결과가 expect{passed_min, failed} 충족"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 로드맵 (M0 → M5)
|
||||||
|
# =============================================================================
|
||||||
|
roadmap:
|
||||||
|
scope_note: >
|
||||||
|
전통 팩터(모멘텀/거래량/수급/실적/매크로/밸류/재무건전성 = spec/08_scoring_rules.yaml SS001)
|
||||||
|
+ ATR 리스크 관리 기본 포함. 최신 기법은 레짐 감지 + 워크포워드 캘리브레이션 + 거래비용
|
||||||
|
반영 평가로 한정(사용자 확정, 2026-07-12). 딥러닝/인트라데이/대체데이터/실거래 집행 제외
|
||||||
|
(은퇴자산 + read-only KIS 거버넌스: governance/rules/06_no_direct_api_trading.yaml 유지).
|
||||||
|
phases:
|
||||||
|
M0:
|
||||||
|
name: "실증 하네스 + 정직성 정리"
|
||||||
|
goal: "완료 주장이 불가능한 구조 확립 — 검증기/증거 규약/CI 편입 + 가짜 검증 제거"
|
||||||
|
exit_gate: "validate_quant_engine_wbs 가 release DAG/CI 노드로 PASS; dotnet test + Playwright evidence 스위트 CI 편입; 디버그 스펙 격리·가짜 PASS 제거"
|
||||||
|
tasks: [QE-M0-01, QE-M0-02, QE-M0-03, QE-M0-04, QE-M0-05, QE-M0-06]
|
||||||
|
M1:
|
||||||
|
name: "수집 파이프라인 배선"
|
||||||
|
goal: "운영 앱이 실제 KIS 데이터를 수집하도록 고아 오케스트레이터 배선 (첫 실데이터 실증)"
|
||||||
|
exit_gate: "Hangfire daily-collection + POST /api/collection/run 으로 kis_collection_* 에 실데이터 적재, Admin Collection 페이지 Playwright 실증"
|
||||||
|
tasks: [QE-M1-01, QE-M1-02, QE-M1-03, QE-M1-04, QE-M1-05]
|
||||||
|
M2:
|
||||||
|
name: "히스토리 시계열 저장소"
|
||||||
|
goal: "모멘텀 팩터·백테스트의 전제인 일봉/매크로 시계열 축적 (2년 백필)"
|
||||||
|
exit_gate: "price_history_daily/macro_history_daily 에 유니버스 2년치; (ticker,date) 중복 0; 거래일 캘린더 대비 gap 0"
|
||||||
|
tasks: [QE-M2-01, QE-M2-02, QE-M2-03, QE-M2-04, QE-M2-05]
|
||||||
|
M3:
|
||||||
|
name: "실데이터 팩터 계산"
|
||||||
|
goal: "SS001 전통 팩터를 PG 히스토리에서 계산해 engine_history 에 적재, 파일 개수 골든커버리지를 수치 패리티로 대체"
|
||||||
|
exit_gate: "factor_output_history 에 유니버스 전체 스코어(0-100); Python 참조 대비 패리티 ≥20 formula tol 1e-9 PASS"
|
||||||
|
tasks: [QE-M3-01, QE-M3-02, QE-M3-03, QE-M3-04, QE-M3-05]
|
||||||
|
M4:
|
||||||
|
name: "백테스팅 + 검증"
|
||||||
|
goal: "point-in-time 데이터만 사용하는 워크포워드 백테스터 + 거래비용 모델 + no-lookahead 게이트 실배선"
|
||||||
|
exit_gate: "Sharpe/MDD/턴오버 JSON 산출; no-lookahead 정상 PASS + 오염 픽스처 FAIL 양방향; T+5/T+20 원장 표본 ≥30"
|
||||||
|
tasks: [QE-M4-01, QE-M4-02, QE-M4-03, QE-M4-04, QE-M4-05]
|
||||||
|
M5:
|
||||||
|
name: "포트폴리오 구성 + 최신 기법"
|
||||||
|
goal: "레짐 감지 + SS001 가중치 워크포워드 캘리브레이션(제약+shrinkage) + 변동성 타게팅 사이징"
|
||||||
|
exit_gate: "백필 전 기간 레짐 라벨; 캘리브레이션 가중치 제약 준수 + OOS Sharpe 정직 보고; 최종 포트폴리오 패킷 캡 준수"
|
||||||
|
tasks: [QE-M5-01, QE-M5-02, QE-M5-03, QE-M5-04]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WBS 작업 목록
|
||||||
|
# =============================================================================
|
||||||
|
tasks:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M0 — 실증 하네스 + 정직성 정리
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
QE-M0-01:
|
||||||
|
title: "WBS 스펙(YAML) + 로드맵 작성, 레거시 로드맵 문서에 포인터 추가"
|
||||||
|
status: DONE
|
||||||
|
depends_on: []
|
||||||
|
owner_files:
|
||||||
|
- spec/60_quant_engine_wbs.yaml
|
||||||
|
- docs/ROADMAP_WBS.md
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { spec_exists: true, legacy_pointer_appended: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M0-01/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-01"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: file_exists
|
||||||
|
paths: [spec/60_quant_engine_wbs.yaml]
|
||||||
|
expect: { min_bytes: 10000 }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: docs/ROADMAP_WBS.md
|
||||||
|
pattern: "QUANT_ENGINE_WBS_V1"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
|
||||||
|
QE-M0-02:
|
||||||
|
title: "증거 검증기 2종 구현 (단일 작업 verifier + 전체 WBS validator) + 유닛테스트"
|
||||||
|
status: DONE
|
||||||
|
depends_on: []
|
||||||
|
owner_files:
|
||||||
|
- tools/verify_wbs_task_v1.py
|
||||||
|
- tools/validate_quant_engine_wbs_v1.py
|
||||||
|
- tests/unit/test_validate_quant_engine_wbs_v1.py
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { self_test: PASS, synthetic_pass_fail_bidirectional: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M0-02/verdict.json, Temp/quant_engine_wbs_v1.json]
|
||||||
|
verification_commands:
|
||||||
|
- "python -m pytest tests/unit/test_validate_quant_engine_wbs_v1.py -q"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M0-02"
|
||||||
|
evidence_checks:
|
||||||
|
- type: file_exists
|
||||||
|
paths:
|
||||||
|
- tools/verify_wbs_task_v1.py
|
||||||
|
- tools/validate_quant_engine_wbs_v1.py
|
||||||
|
- tests/unit/test_validate_quant_engine_wbs_v1.py
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: tools/validate_quant_engine_wbs_v1.py
|
||||||
|
pattern: "def main"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
|
||||||
|
QE-M0-03:
|
||||||
|
title: "Playwright 정직성 정리 + evidence 프로젝트 + npm 스크립트"
|
||||||
|
status: DONE
|
||||||
|
depends_on: [QE-M0-02]
|
||||||
|
owner_files:
|
||||||
|
- playwright.config.ts
|
||||||
|
- package.json
|
||||||
|
- tests/e2e/archive/
|
||||||
|
notes: >
|
||||||
|
디버그 스펙(~17개: debug-login, html-debug, wasm-test, framework-check, console-check,
|
||||||
|
screenshot-diagnosis, inspect-page, login* 변형 등)을 tests/e2e/archive/ 로 이동하고
|
||||||
|
testIgnore 로 제외. full-validation.spec.ts 의 assert 없는 가짜 "[PASS]" 배너 테스트
|
||||||
|
제거(파일째 archive). evidence 프로젝트: testDir tests/e2e/evidence, screenshot 'on',
|
||||||
|
trace 'on', JSON reporter → Temp/evidence/playwright-last-run.json.
|
||||||
|
npm 스크립트: verify:task / verify:wbs / test:e2e / test:evidence
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { default_project_specs: ["admin-pages.spec.ts"], fake_pass_removed: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M0-03/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-03"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: file_exists
|
||||||
|
paths: [tests/e2e/archive]
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: playwright.config.ts
|
||||||
|
pattern: "evidence"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: package.json
|
||||||
|
pattern: "verify:task"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: tests/e2e/full-validation.spec.ts
|
||||||
|
pattern: ".*"
|
||||||
|
expect: { max_matches: 0 } # 파일이 기본 testDir 에 더 이상 존재하지 않아야 함
|
||||||
|
|
||||||
|
QE-M0-04:
|
||||||
|
title: "CI에 dotnet test 편입 + 고아 QuantEngine.Web.Tests 처리"
|
||||||
|
status: DONE
|
||||||
|
depends_on: []
|
||||||
|
owner_files:
|
||||||
|
- .gitea/workflows/ci.yml
|
||||||
|
- src/dotnet/QuantEngine.Web.Tests/
|
||||||
|
notes: >
|
||||||
|
QuantEngine.Web.Tests/DashboardComponentTests.cs 는 csproj 없는 고아(폐기된 Blazor 대상).
|
||||||
|
현 Razor Pages UI 에 맞지 않으면 삭제. ci.yml 에 dotnet test 스텝 추가.
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { ci_has_dotnet_test: true, core_tests_green: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M0-04/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M0-04"
|
||||||
|
evidence_checks:
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: .gitea/workflows/ci.yml
|
||||||
|
pattern: "dotnet test"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
|
||||||
|
QE-M0-05:
|
||||||
|
title: "release DAG + CI 에 validate_quant_engine_wbs 게이트 노드 등록"
|
||||||
|
status: DONE
|
||||||
|
depends_on: [QE-M0-02]
|
||||||
|
owner_files:
|
||||||
|
- spec/41_release_dag.yaml
|
||||||
|
- .gitea/workflows/ci.yml
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { dag_node: validate_quant_engine_wbs, ci_step: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M0-05/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-05"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: spec/41_release_dag.yaml
|
||||||
|
pattern: "validate_quant_engine_wbs"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: .gitea/workflows/ci.yml
|
||||||
|
pattern: "validate_quant_engine_wbs_v1"
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
|
||||||
|
QE-M0-06:
|
||||||
|
title: "골든커버리지 정직성 표기 (coverage_basis: FILE_COUNT_ONLY)"
|
||||||
|
status: DONE
|
||||||
|
depends_on: []
|
||||||
|
owner_files:
|
||||||
|
- tools/validate_golden_coverage_100.py
|
||||||
|
notes: >
|
||||||
|
골든 테스트 174개는 실행되지 않는 placeholder. 커버리지 판정 출력에
|
||||||
|
coverage_basis: FILE_COUNT_ONLY 필드를 추가해 실체를 명시(삭제는 M3 패리티 대체 후).
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { honesty_field: FILE_COUNT_ONLY }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M0-06/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "python tools/validate_golden_coverage_100.py"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M0-06"
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/golden_coverage_100_v1.json
|
||||||
|
expect: { coverage_basis: FILE_COUNT_ONLY }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M1 — 수집 파이프라인 배선 (첫 실데이터 실증)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
QE-M1-01:
|
||||||
|
title: "KisDataCollectionOrchestrator DI 등록 + daily-collection Hangfire 잡 실구현"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M0-02]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Web/Program.cs
|
||||||
|
- src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
|
||||||
|
notes: >
|
||||||
|
Program.cs 에 PriceDataNormalizer / SourcePriorityResolver / ICollectionOrchestrator →
|
||||||
|
KisDataCollectionOrchestrator 등록. RunDailyCollectionAsync 의 Task.Delay 시뮬레이션을
|
||||||
|
IServiceScopeFactory 스코프 → 오케스트레이터 호출로 교체 (runId "daily-yyyyMMdd-HHmmss").
|
||||||
|
완료 로그: "Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors".
|
||||||
|
상태값은 대문자 COMPLETED / COMPLETED_WITH_ERRORS (KisDataCollectionOrchestrator.cs:103).
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { runs_completed_min: 1, snapshots_min: 5, hangfire_job: daily-collection }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M1-01/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-01"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: pg_query
|
||||||
|
sql: >
|
||||||
|
SELECT count(*) FROM quantengine.kis_collection_runs
|
||||||
|
WHERE status LIKE 'COMPLETED%' AND total_snapshots >= 5
|
||||||
|
AND started_at >= (now() - interval '24 hours')::text
|
||||||
|
expect: { min: 1 }
|
||||||
|
- type: pg_query
|
||||||
|
sql: >
|
||||||
|
SELECT count(DISTINCT s.ticker) FROM quantengine.kis_collection_snapshots s
|
||||||
|
JOIN quantengine.kis_collection_runs r ON r.run_id = s.run_id
|
||||||
|
WHERE r.started_at >= (now() - interval '24 hours')::text
|
||||||
|
expect: { min: 5 }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
|
||||||
|
pattern: 'Collection run .+ completed: \d+ snapshots'
|
||||||
|
expect: { min_matches: 1, max_age_hours: 24 }
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/kis_dotnet_collection_v1.json
|
||||||
|
expect: { gate: PASS }
|
||||||
|
|
||||||
|
QE-M1-02:
|
||||||
|
title: "Admin Collection 페이지 FE 실증 (실제 run 렌더링을 Playwright 로 증명)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M1-01, QE-M0-03]
|
||||||
|
owner_files:
|
||||||
|
- tests/e2e/evidence/qe-m1-02-collection-run.spec.ts
|
||||||
|
notes: >
|
||||||
|
필수 3요소: (a) 기대값을 /api/collection/runs API 에서 조회(하드코딩 금지),
|
||||||
|
(b) /Admin/Collection DOM 에서 run_id·스냅샷 수·상태 배지를 기대값과 assert,
|
||||||
|
(c) assert 시점 스크린샷 → Temp/evidence/QE-M1-02/screenshots/{01-collection-page,02-run-detail}.png
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { spec_passed: 1, screenshots: 2, dom_equals_api: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M1-02/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m1-02-collection-run.spec.ts"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M1-02"
|
||||||
|
evidence_checks:
|
||||||
|
- type: playwright_report
|
||||||
|
report: Temp/evidence/playwright-last-run.json
|
||||||
|
spec_file: qe-m1-02-collection-run.spec.ts
|
||||||
|
expect: { passed_min: 1, failed: 0 }
|
||||||
|
- type: file_exists
|
||||||
|
paths:
|
||||||
|
- Temp/evidence/QE-M1-02/screenshots/01-collection-page.png
|
||||||
|
- Temp/evidence/QE-M1-02/screenshots/02-run-detail.png
|
||||||
|
expect: { min_bytes: 10000 }
|
||||||
|
|
||||||
|
QE-M1-03:
|
||||||
|
title: "POST /api/collection/run 실구현 (BackgroundJob.Enqueue + 인증 필수화)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M1-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
|
||||||
|
notes: >
|
||||||
|
202 no-op 스텁을 Hangfire BackgroundJob.Enqueue(오케스트레이터 실행)로 교체,
|
||||||
|
응답에 {runId} 포함. AllowAnonymous 제거(쿠키 인증).
|
||||||
|
로그: "Collection run {RunId} enqueued".
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { returns_run_id: true, auth_required: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M1-03/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-03"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
|
||||||
|
pattern: 'Collection run .+ enqueued'
|
||||||
|
expect: { min_matches: 1, max_age_hours: 24 }
|
||||||
|
- type: pg_query
|
||||||
|
sql: >
|
||||||
|
SELECT count(*) FROM quantengine.kis_collection_runs
|
||||||
|
WHERE run_id LIKE 'api-%' AND started_at >= (now() - interval '24 hours')::text
|
||||||
|
expect: { min: 1 }
|
||||||
|
|
||||||
|
QE-M1-04:
|
||||||
|
title: "오케스트레이터 로깅 복원 + 출력 아티팩트 표준화 + 멀티소스 폴백 배선"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M1-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
|
||||||
|
notes: >
|
||||||
|
"// Log: skipped" → ILogger<KisDataCollectionOrchestrator> 복원.
|
||||||
|
출력 경로 Path.GetTempPath() → <repo>/Temp/kis_dotnet_collection_v1.json,
|
||||||
|
형식 {formula_id: KIS_DOTNET_COLLECTION_V1, gate, summary{success_count, error_count, source_counts}}.
|
||||||
|
SourcePriorityResolver 를 통해 Naver/Yahoo 폴백 경로 활성화.
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { gate: PASS, source_counts_min: 1, error_rows_on_bad_ticker: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M1-04/verdict.json, Temp/kis_dotnet_collection_v1.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-04"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/kis_dotnet_collection_v1.json
|
||||||
|
expect: { formula_id: KIS_DOTNET_COLLECTION_V1, gate: PASS }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
|
||||||
|
pattern: 'Collecting ticker'
|
||||||
|
expect: { min_matches: 1, max_age_hours: 24 }
|
||||||
|
|
||||||
|
QE-M1-05:
|
||||||
|
title: "티커 유니버스를 GatherTradingData 파서/DB 설정에서 로드 (하드코딩 제거)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M1-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { distinct_tickers_equals_universe: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M1-05/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-05"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
|
||||||
|
pattern: '005930.+000660.+051910'
|
||||||
|
expect: { max_matches: 0 } # 하드코딩 티커 배열 부재
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M2 — 히스토리 시계열 저장소
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
QE-M2-01:
|
||||||
|
title: "V6 마이그레이션: price_history_daily + macro_history_daily"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M1-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Infrastructure/Migrations/V6__Add_Market_Time_Series.sql
|
||||||
|
notes: >
|
||||||
|
price_history_daily(ticker, trade_date, open/high/low/close numeric, volume bigint,
|
||||||
|
source text, collected_at timestamptz, PK(ticker, trade_date));
|
||||||
|
macro_history_daily(symbol, trade_date, value numeric, source, PK(symbol, trade_date)).
|
||||||
|
DbUp 마이그레이션 추가 시 docs/db/quantengine.dbml 동기화 필수 (CLAUDE.md 규칙 — 아래 체크로 강제).
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { tables_created: 2 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M2-01/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-01"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: pg_query
|
||||||
|
sql: >
|
||||||
|
SELECT count(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema='quantengine' AND table_name IN ('price_history_daily','macro_history_daily')
|
||||||
|
expect: { equals: 2 }
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: docs/db/quantengine.dbml
|
||||||
|
pattern: 'price_history_daily'
|
||||||
|
expect: { min_matches: 1 } # DBML 동기화 강제
|
||||||
|
|
||||||
|
QE-M2-02:
|
||||||
|
title: "일봉 OHLCV 시계열 적재 (daily run 마다 upsert, 재실행 중복 0)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { rows_per_ticker_min: 1, duplicate_on_rerun: 0 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M2-02/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-02"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: pg_query
|
||||||
|
sql: "SELECT count(*) FROM quantengine.price_history_daily WHERE collected_at >= now() - interval '24 hours'"
|
||||||
|
expect: { min: 1 }
|
||||||
|
- type: pg_query
|
||||||
|
sql: >
|
||||||
|
SELECT count(*) FROM (SELECT ticker, trade_date, count(*) c
|
||||||
|
FROM quantengine.price_history_daily GROUP BY 1,2 HAVING count(*) > 1) d
|
||||||
|
expect: { equals: 0 }
|
||||||
|
|
||||||
|
QE-M2-03:
|
||||||
|
title: "2년치 백필 툴 (KIS chart API 페이지네이션 + rate-limit, 매크로는 yfinance→PG)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Tools/
|
||||||
|
- src/quant_engine/macro_index_collection_v1.py
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { bars_per_ticker_min: 480, macro_bars_min: 480 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M2-03/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-03"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: pg_query
|
||||||
|
sql: "SELECT coalesce(min(c),0) FROM (SELECT count(*) c FROM quantengine.price_history_daily GROUP BY ticker) t"
|
||||||
|
expect: { min: 480 }
|
||||||
|
- type: pg_query
|
||||||
|
sql: "SELECT count(*) FROM quantengine.macro_history_daily WHERE symbol IN ('KOSPI','KOSDAQ')"
|
||||||
|
expect: { min: 960 }
|
||||||
|
|
||||||
|
QE-M2-04:
|
||||||
|
title: "시계열 무결성 게이트 (거래일 캘린더 대비 gap 0, 가격 sanity)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-03]
|
||||||
|
owner_files:
|
||||||
|
- tools/validate_price_history_integrity_v1.py
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { gap_count: 0, invalid_price_rows: 0 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M2-04/verdict.json, Temp/price_history_integrity_v1.json]
|
||||||
|
verification_commands:
|
||||||
|
- "python tools/validate_price_history_integrity_v1.py"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M2-04"
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/price_history_integrity_v1.json
|
||||||
|
expect: { gate: PASS, gap_count: 0 }
|
||||||
|
|
||||||
|
QE-M2-05:
|
||||||
|
title: "히스토리 현황 FE (per-ticker bar 수/기간/gap — API 값과 DOM 대조)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-03, QE-M0-03]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/
|
||||||
|
- tests/e2e/evidence/qe-m2-05-history-tab.spec.ts
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { spec_passed: 1, screenshots_min: 1 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M2-05/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m2-05-history-tab.spec.ts"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M2-05"
|
||||||
|
evidence_checks:
|
||||||
|
- type: playwright_report
|
||||||
|
report: Temp/evidence/playwright-last-run.json
|
||||||
|
spec_file: qe-m2-05-history-tab.spec.ts
|
||||||
|
expect: { passed_min: 1, failed: 0 }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M3 — 실데이터 팩터 계산
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
QE-M3-01:
|
||||||
|
title: "Point-in-time 리더 (GetBarsAsOf — lookahead 구조적 차단 + xUnit 증명)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-03]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Infrastructure/Repositories/
|
||||||
|
- src/dotnet/QuantEngine.Core.Tests/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { asof_leak_tests_green: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M3-01/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --filter PriceHistoryReader"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M3-01"
|
||||||
|
evidence_checks:
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: src/dotnet/QuantEngine.Core.Tests/**/*.cs
|
||||||
|
pattern: 'GetBarsAsOf'
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
|
||||||
|
QE-M3-02:
|
||||||
|
title: "전통 팩터 계산기 (모멘텀 20/60/120d·RS, 저변동성 ATR%·stdev·beta, 밸류/퀄리티)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M3-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Core/Domain/
|
||||||
|
- tools/validate_factor_parity_v1.py
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { parity_formulas_min: 20, tolerance: 1e-9 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M3-02/verdict.json, Temp/factor_parity_v1.json]
|
||||||
|
verification_commands:
|
||||||
|
- "python tools/validate_factor_parity_v1.py"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M3-02"
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/factor_parity_v1.json
|
||||||
|
expect: { gate: PASS, compared_count: ">=20" }
|
||||||
|
|
||||||
|
QE-M3-03:
|
||||||
|
title: "SS001 합성 스코어 + HF001-09 → engine_history.factor_output_history 적재"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M3-02]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Application/Services/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { scored_universe_full: true, score_range_0_100: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M3-03/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-03"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: pg_query
|
||||||
|
sql: "SELECT count(*) FROM engine_history.factor_output_history WHERE created_at >= now() - interval '24 hours'"
|
||||||
|
expect: { min: 5 }
|
||||||
|
|
||||||
|
QE-M3-04:
|
||||||
|
title: "PipelineOrchestrator 정직화 (1-2단계 실구현, 나머지 STUBBED 표기 — mock PASS 금지)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M3-03]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { computed_steps_min: 2, stub_steps_marked: STUBBED }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M3-04/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-04"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: log_pattern
|
||||||
|
file_glob: src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs
|
||||||
|
pattern: 'STUBBED'
|
||||||
|
expect: { min_matches: 1 }
|
||||||
|
|
||||||
|
QE-M3-05:
|
||||||
|
title: "스코어 FE (SS001 테이블 — factor_output_history 값과 DOM 대조)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M3-03, QE-M0-03]
|
||||||
|
owner_files:
|
||||||
|
- tests/e2e/evidence/qe-m3-05-scores.spec.ts
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { spec_passed: 1 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M3-05/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m3-05-scores.spec.ts"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M3-05"
|
||||||
|
evidence_checks:
|
||||||
|
- type: playwright_report
|
||||||
|
report: Temp/evidence/playwright-last-run.json
|
||||||
|
spec_file: qe-m3-05-scores.spec.ts
|
||||||
|
expect: { passed_min: 1, failed: 0 }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M4 — 백테스팅 + 검증
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
QE-M4-01:
|
||||||
|
title: "백테스터 + 거래비용 모델 (Sharpe/MDD/턴오버/비용 드래그 JSON)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M3-03]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Core/Domain/Backtester.cs
|
||||||
|
- src/dotnet/QuantEngine.Tools/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { metrics_populated: [sharpe, mdd, turnover, cost_drag] }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M4-01/verdict.json, Temp/backtest_result_v1.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-01"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/backtest_result_v1.json
|
||||||
|
expect: { gate: PASS }
|
||||||
|
|
||||||
|
QE-M4-02:
|
||||||
|
title: "no-lookahead 게이트 실배선 (정상 PASS + 오염 픽스처 FAIL 양방향 검증)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M4-01]
|
||||||
|
owner_files:
|
||||||
|
- tools/validate_no_lookahead_bias_v1.py
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { real_run: PASS, corrupted_fixture: FAIL }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M4-02/verdict.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-02"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/no_lookahead_bias_v1.json
|
||||||
|
expect: { gate: PASS }
|
||||||
|
|
||||||
|
QE-M4-03:
|
||||||
|
title: "워크포워드 하네스 (24m train / 6m test 롤링, 윈도우 ≥4)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M4-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Tools/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { windows_min: 4, oos_metrics_nonnull: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M4-03/verdict.json, Temp/walk_forward_v1.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-03"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/walk_forward_v1.json
|
||||||
|
expect: { gate: PASS, windows: ">=4" }
|
||||||
|
|
||||||
|
QE-M4-04:
|
||||||
|
title: "T+5/T+20 성과 원장 (prediction_accuracy 실표본 재계산, t5_sample≥30)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-03]
|
||||||
|
owner_files:
|
||||||
|
- tools/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { t5_sample_min: 30 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M4-04/verdict.json, Temp/prediction_accuracy_harness_v2.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-04"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/prediction_accuracy_harness_v2.json
|
||||||
|
expect: { t5_sample: ">=30" }
|
||||||
|
|
||||||
|
QE-M4-05:
|
||||||
|
title: "백테스트 결과 FE (에쿼티커브/Sharpe/MDD — backtest_result_v1.json 값과 DOM 대조)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M4-01, QE-M0-03]
|
||||||
|
owner_files:
|
||||||
|
- tests/e2e/evidence/qe-m4-05-backtest.spec.ts
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { spec_passed: 1 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M4-05/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m4-05-backtest.spec.ts"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M4-05"
|
||||||
|
evidence_checks:
|
||||||
|
- type: playwright_report
|
||||||
|
report: Temp/evidence/playwright-last-run.json
|
||||||
|
spec_file: qe-m4-05-backtest.spec.ts
|
||||||
|
expect: { passed_min: 1, failed: 0 }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M5 — 포트폴리오 구성 + 최신 기법
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
QE-M5-01:
|
||||||
|
title: "레짐 감지기 (spec/11_market_regime.yaml — 실제 매크로 시계열, 전 거래일 라벨)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M2-03]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Core/Domain/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { regime_labels_full_window: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M5-01/verdict.json, Temp/market_regime_v1.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-01"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/market_regime_v1.json
|
||||||
|
expect: { gate: PASS }
|
||||||
|
|
||||||
|
QE-M5-02:
|
||||||
|
title: "SS001 가중치 워크포워드 캘리브레이션 (±50% 제약 + shrinkage λ=0.5, 정직 보고)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M4-03, QE-M5-01]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Tools/
|
||||||
|
notes: "게이트는 방법론 필드(제약 준수, OOS 비교 존재)를 검증 — 캘리브레이션이 '이겨야' PASS 가 아님"
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { weights_within_bounds: true, oos_comparison_reported: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M5-02/verdict.json, Temp/weight_calibration_v1.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-02"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/weight_calibration_v1.json
|
||||||
|
expect: { gate: PASS }
|
||||||
|
|
||||||
|
QE-M5-03:
|
||||||
|
title: "변동성 타게팅 사이징 + heat/집중도 캡 합성 → 최종 목표 포트폴리오 패킷"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M5-02]
|
||||||
|
owner_files:
|
||||||
|
- src/dotnet/QuantEngine.Application/Services/
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { weights_sum_lte_100: true, all_caps_satisfied: true }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M5-03/verdict.json, Temp/target_portfolio_v1.json]
|
||||||
|
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-03"]
|
||||||
|
evidence_checks:
|
||||||
|
- type: json_gate
|
||||||
|
path: Temp/target_portfolio_v1.json
|
||||||
|
expect: { gate: PASS }
|
||||||
|
|
||||||
|
QE-M5-04:
|
||||||
|
title: "포트폴리오·레짐 대시보드 FE (레짐 배지·목표 가중치 — API 값과 DOM 대조)"
|
||||||
|
status: PENDING
|
||||||
|
depends_on: [QE-M5-03, QE-M0-03]
|
||||||
|
owner_files:
|
||||||
|
- tests/e2e/evidence/qe-m5-04-portfolio.spec.ts
|
||||||
|
success_criteria:
|
||||||
|
expected_success_value: { spec_passed: 1 }
|
||||||
|
evidence_artifacts: [Temp/evidence/QE-M5-04/verdict.json]
|
||||||
|
verification_commands:
|
||||||
|
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m5-04-portfolio.spec.ts"
|
||||||
|
- "python tools/verify_wbs_task_v1.py --task QE-M5-04"
|
||||||
|
evidence_checks:
|
||||||
|
- type: playwright_report
|
||||||
|
report: Temp/evidence/playwright-last-run.json
|
||||||
|
spec_file: qe-m5-04-portfolio.spec.ts
|
||||||
|
expect: { passed_min: 1, failed: 0 }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
formula_id: DOTNET_POSTGRESQL_JSON_CUTOVER_V1
|
||||||
|
version: 1
|
||||||
|
authority: spec/02_data_contract.yaml
|
||||||
|
canonical_runtime:
|
||||||
|
collector: src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
|
||||||
|
seed_ingestion: src/dotnet/QuantEngine.Application/Services/JsonSeedIngestionService.cs
|
||||||
|
storage: src/dotnet/QuantEngine.Infrastructure/Repositories/CollectionRepository.cs
|
||||||
|
migration: src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql
|
||||||
|
output: Temp/kis_dotnet_collection_v1.json
|
||||||
|
legacy_policy:
|
||||||
|
python_collector: migration_only
|
||||||
|
sqlite_store: migration_only
|
||||||
|
xlsx_runtime_input: forbidden
|
||||||
|
gates:
|
||||||
|
- dotnet_collector_registered
|
||||||
|
- postgresql_collection_repository_registered
|
||||||
|
- json_seed_ingestion_registered
|
||||||
|
- dbup_v5_embedded
|
||||||
|
- xlsx_not_runtime_dependency
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
formula_id: DOTNET_FORMULA_CANONICAL_COVERAGE_V1
|
||||||
|
version: 1
|
||||||
|
authority:
|
||||||
|
registry: spec/13b_harness_formulas.yaml
|
||||||
|
lifecycle: spec/51_formula_lifecycle_registry.yaml
|
||||||
|
implementation: src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs
|
||||||
|
verification: src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs
|
||||||
|
active_formula_ids:
|
||||||
|
- ANTI_CHASE_V1
|
||||||
|
- CASH_RECOVERY_V1
|
||||||
|
- COMPREHENSIVE_PROPOSAL_V1
|
||||||
|
- DFG_V1
|
||||||
|
- INTRADAY_V1
|
||||||
|
- PORTFOLIO_HEALTH_V1
|
||||||
|
- RS_V2_FUSION
|
||||||
|
- STOP_BREACH_V1
|
||||||
|
- TICK_NORM_V1
|
||||||
|
success_criteria:
|
||||||
|
coverage_audit_true_missing_count: 0
|
||||||
|
dotnet_build_errors: 0
|
||||||
|
canonical_test_gate: PASS
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
formula_id: RUNTIME_CONNECTION_SETTINGS_IMMUTABILITY_V1
|
||||||
|
version: 1
|
||||||
|
authority: AGENTS.md
|
||||||
|
setting_key: ConnectionStrings__DefaultConnection
|
||||||
|
owner: operations
|
||||||
|
policy:
|
||||||
|
source: runtime_environment_or_external_settings
|
||||||
|
application_may_read: true
|
||||||
|
application_may_write: false
|
||||||
|
secret_value_in_repository: allowed_only_when_explicitly_restoring_authoritative_git_value
|
||||||
|
evidence: Temp/runtime_connection_settings_immutability_v1.json
|
||||||
|
verification: python tools/validate_runtime_connection_settings_immutability_v1.py
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
formula_id: MARKET_TIME_SERIES_SCHEMA_V1
|
||||||
|
version: 1
|
||||||
|
authority: spec/60_quant_engine_wbs.yaml
|
||||||
|
migration: src/dotnet/QuantEngine.Infrastructure/Migrations/V6__Add_Market_Time_Series.sql
|
||||||
|
dbml: docs/db/quantengine.dbml
|
||||||
|
tables:
|
||||||
|
- quantengine.price_history_daily
|
||||||
|
- quantengine.macro_history_daily
|
||||||
|
runtime_database_status: DATA_GATED
|
||||||
|
verification: python tools/validate_market_time_series_schema_v1.py
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace QuantEngine.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical application path for recording factor evidence, decisions, and
|
||||||
|
/// realized outcomes in the normalized PostgreSQL learning store.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DecisionLearningService
|
||||||
|
{
|
||||||
|
private readonly INormalizedLearningStore _store;
|
||||||
|
|
||||||
|
public DecisionLearningService(INormalizedLearningStore store) => _store = store;
|
||||||
|
|
||||||
|
public async Task<Guid> RecordDecisionAsync(
|
||||||
|
string decisionKey,
|
||||||
|
DateTimeOffset decidedAt,
|
||||||
|
string instrumentId,
|
||||||
|
string action,
|
||||||
|
string gate,
|
||||||
|
decimal? score,
|
||||||
|
string sourceVersion,
|
||||||
|
IEnumerable<FactorEvidenceInput> factors,
|
||||||
|
object? trace = null,
|
||||||
|
object? provenance = null)
|
||||||
|
{
|
||||||
|
var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord(
|
||||||
|
decisionKey,
|
||||||
|
decidedAt,
|
||||||
|
instrumentId,
|
||||||
|
action,
|
||||||
|
gate,
|
||||||
|
score,
|
||||||
|
sourceVersion,
|
||||||
|
JsonSerializer.Serialize(trace ?? new { }),
|
||||||
|
JsonSerializer.Serialize(provenance ?? new { })));
|
||||||
|
|
||||||
|
foreach (var factor in factors)
|
||||||
|
{
|
||||||
|
var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord(
|
||||||
|
factor.ObservedAt,
|
||||||
|
instrumentId,
|
||||||
|
factor.SourceName,
|
||||||
|
sourceVersion,
|
||||||
|
factor.PayloadJson,
|
||||||
|
factor.ProvenanceJson));
|
||||||
|
var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord(
|
||||||
|
observationId,
|
||||||
|
factor.FactorObservationId,
|
||||||
|
factor.FactorId,
|
||||||
|
factor.FactorVersion,
|
||||||
|
factor.ObservedAt,
|
||||||
|
factor.NumericValue,
|
||||||
|
factor.TextValue,
|
||||||
|
factor.Gate,
|
||||||
|
factor.ProvenanceJson));
|
||||||
|
await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, factor.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decisionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RecordOutcomeAsync(
|
||||||
|
Guid decisionId,
|
||||||
|
int horizonDays,
|
||||||
|
DateTimeOffset evaluatedAt,
|
||||||
|
decimal? realizedReturn,
|
||||||
|
decimal? benchmarkReturn,
|
||||||
|
string outcomeClass,
|
||||||
|
string evaluationGate,
|
||||||
|
object? provenance = null)
|
||||||
|
{
|
||||||
|
decimal? excessReturn = realizedReturn.HasValue && benchmarkReturn.HasValue
|
||||||
|
? realizedReturn.Value - benchmarkReturn.Value
|
||||||
|
: null;
|
||||||
|
return _store.AppendOutcomeAsync(new OutcomeEvaluationRecord(
|
||||||
|
decisionId,
|
||||||
|
horizonDays,
|
||||||
|
evaluatedAt,
|
||||||
|
realizedReturn,
|
||||||
|
benchmarkReturn,
|
||||||
|
excessReturn,
|
||||||
|
outcomeClass,
|
||||||
|
evaluationGate,
|
||||||
|
JsonSerializer.Serialize(provenance ?? new { })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FactorEvidenceInput(
|
||||||
|
Guid FactorObservationId,
|
||||||
|
string FactorId,
|
||||||
|
string FactorVersion,
|
||||||
|
DateTimeOffset ObservedAt,
|
||||||
|
decimal? NumericValue,
|
||||||
|
string? TextValue,
|
||||||
|
string Gate,
|
||||||
|
string Role,
|
||||||
|
string SourceName,
|
||||||
|
string PayloadJson,
|
||||||
|
string ProvenanceJson);
|
||||||
@@ -8,10 +8,12 @@ namespace QuantEngine.Application.Services
|
|||||||
public class FormulaService
|
public class FormulaService
|
||||||
{
|
{
|
||||||
private readonly IPostgresqlHistoryStore _historyStore;
|
private readonly IPostgresqlHistoryStore _historyStore;
|
||||||
|
private readonly DecisionLearningService _learningService;
|
||||||
|
|
||||||
public FormulaService(IPostgresqlHistoryStore historyStore)
|
public FormulaService(IPostgresqlHistoryStore historyStore, DecisionLearningService learningService)
|
||||||
{
|
{
|
||||||
_historyStore = historyStore;
|
_historyStore = historyStore;
|
||||||
|
_learningService = learningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
||||||
@@ -23,6 +25,27 @@ namespace QuantEngine.Application.Services
|
|||||||
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
|
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
|
||||||
=> FormulaEngine.ComputeFinalDecision(ctx);
|
=> FormulaEngine.ComputeFinalDecision(ctx);
|
||||||
|
|
||||||
|
public async Task<Guid> ComputeAndRecordFinalDecisionAsync(
|
||||||
|
Dictionary<string, object> ctx,
|
||||||
|
string decisionKey,
|
||||||
|
string instrumentId,
|
||||||
|
string sourceVersion,
|
||||||
|
IEnumerable<FactorEvidenceInput> factorEvidence)
|
||||||
|
{
|
||||||
|
var decision = ComputeFinalDecision(ctx);
|
||||||
|
return await _learningService.RecordDecisionAsync(
|
||||||
|
decisionKey,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
instrumentId,
|
||||||
|
decision.FinalAction,
|
||||||
|
"PASS",
|
||||||
|
Convert.ToDecimal(decision.PriorityScore),
|
||||||
|
sourceVersion,
|
||||||
|
factorEvidence,
|
||||||
|
new { context_keys = ctx.Keys.OrderBy(key => key).ToArray() },
|
||||||
|
new { formula = "FormulaEngine.ComputeFinalDecision", source_version = sourceVersion });
|
||||||
|
}
|
||||||
|
|
||||||
public CashShortfallResult ComputeCashShortfallHarness(
|
public CashShortfallResult ComputeCashShortfallHarness(
|
||||||
Dictionary<string, object> asResult,
|
Dictionary<string, object> asResult,
|
||||||
double totalAsset,
|
double totalAsset,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace QuantEngine.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-first seed ingestion path. XLSX conversion remains an external
|
||||||
|
/// preparation step; the runtime application only reads canonical JSON and
|
||||||
|
/// persists normalized snapshots to PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JsonSeedIngestionService
|
||||||
|
{
|
||||||
|
private readonly GatherTradingDataParser _parser;
|
||||||
|
private readonly ICollectionRepository _repository;
|
||||||
|
private readonly ILogger<JsonSeedIngestionService> _logger;
|
||||||
|
|
||||||
|
public JsonSeedIngestionService(
|
||||||
|
GatherTradingDataParser parser,
|
||||||
|
ICollectionRepository repository,
|
||||||
|
ILogger<JsonSeedIngestionService> logger)
|
||||||
|
{
|
||||||
|
_parser = parser;
|
||||||
|
_repository = repository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CollectionRunResult> IngestAsync(string jsonPath, string runId)
|
||||||
|
{
|
||||||
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
|
var rows = _parser.ParseGatherTradingData(jsonPath);
|
||||||
|
await _repository.SaveRunAsync(new CollectionRunRecord(
|
||||||
|
runId, "RUNNING", startedAt.ToString("O"), null, rows.Count, 0));
|
||||||
|
|
||||||
|
var errors = 0;
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (!row.TryGetValue("Ticker", out var tickerValue) || string.IsNullOrWhiteSpace(tickerValue?.ToString()))
|
||||||
|
{
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticker = tickerValue.ToString()!;
|
||||||
|
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
|
||||||
|
runId,
|
||||||
|
"data_feed",
|
||||||
|
ticker,
|
||||||
|
"json_seed",
|
||||||
|
JsonSerializer.Serialize(row),
|
||||||
|
startedAt.ToString("O")));
|
||||||
|
}
|
||||||
|
|
||||||
|
var finishedAt = DateTimeOffset.UtcNow;
|
||||||
|
var status = rows.Count > 0 && errors == 0 ? "COMPLETED" : "COMPLETED_WITH_ERRORS";
|
||||||
|
await _repository.UpdateRunStatusAsync(runId, status, finishedAt.ToString("O"), rows.Count - errors, errors);
|
||||||
|
_logger.LogInformation("JSON seed ingestion {RunId} completed: {Snapshots} snapshots, {Errors} errors", runId, rows.Count - errors, errors);
|
||||||
|
|
||||||
|
return new CollectionRunResult
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
Status = status,
|
||||||
|
StartedAt = startedAt.ToString("O"),
|
||||||
|
FinishedAt = finishedAt.ToString("O"),
|
||||||
|
SuccessCount = rows.Count - errors,
|
||||||
|
ErrorCount = errors,
|
||||||
|
Rows = rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
using QuantEngine.Application.Interfaces;
|
using QuantEngine.Application.Interfaces;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
@@ -13,19 +14,20 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
private readonly ICollectionRepository _repository;
|
private readonly ICollectionRepository _repository;
|
||||||
private readonly PriceDataNormalizer _normalizer;
|
private readonly PriceDataNormalizer _normalizer;
|
||||||
private readonly SourcePriorityResolver _priorityResolver;
|
private readonly SourcePriorityResolver _priorityResolver;
|
||||||
// Logging removed for simplicity
|
private readonly ILogger<KisDataCollectionOrchestrator> _logger;
|
||||||
|
|
||||||
public KisDataCollectionOrchestrator(
|
public KisDataCollectionOrchestrator(
|
||||||
IKisApiClient kisApiClient,
|
IKisApiClient kisApiClient,
|
||||||
ICollectionRepository repository,
|
ICollectionRepository repository,
|
||||||
PriceDataNormalizer normalizer,
|
PriceDataNormalizer normalizer,
|
||||||
SourcePriorityResolver priorityResolver)
|
SourcePriorityResolver priorityResolver,
|
||||||
|
ILogger<KisDataCollectionOrchestrator> logger)
|
||||||
{
|
{
|
||||||
_kisApiClient = kisApiClient;
|
_kisApiClient = kisApiClient;
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_normalizer = normalizer;
|
_normalizer = normalizer;
|
||||||
_priorityResolver = priorityResolver;
|
_priorityResolver = priorityResolver;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
||||||
@@ -42,7 +44,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogInformation("Starting collection run {RunId}", runId);
|
||||||
|
|
||||||
var kisSource = new KisApiPriceSource(_kisApiClient);
|
var kisSource = new KisApiPriceSource(_kisApiClient);
|
||||||
var rows = new List<Dictionary<string, object>>();
|
var rows = new List<Dictionary<string, object>>();
|
||||||
@@ -53,34 +55,61 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogInformation("Collecting ticker {Ticker} (run {RunId})", ticker, runId);
|
||||||
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
|
||||||
|
CollectionSnapshotRecord? cachedSnapshot = null;
|
||||||
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
if (IsMarketClosed())
|
||||||
var (normalized, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false);
|
{
|
||||||
|
var latest = await _repository.GetLatestSnapshotsForTickerAsync(ticker, 1);
|
||||||
|
var todayPrefix = DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd");
|
||||||
|
if (latest.Count > 0 && latest[0].CapturedAt.StartsWith(todayPrefix))
|
||||||
|
{
|
||||||
|
cachedSnapshot = latest[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, object> normalized;
|
||||||
|
string sourceName;
|
||||||
|
|
||||||
|
if (cachedSnapshot != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Cache hit for ticker {Ticker} (run {RunId})", ticker, runId);
|
||||||
|
normalized = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedSnapshot.PayloadJson)
|
||||||
|
?? new Dictionary<string, object>();
|
||||||
|
sourceName = cachedSnapshot.SourceName.EndsWith(" (Cached)")
|
||||||
|
? cachedSnapshot.SourceName
|
||||||
|
: cachedSnapshot.SourceName + " (Cached)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
||||||
|
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
||||||
|
var (norm, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false);
|
||||||
|
normalized = norm;
|
||||||
|
sourceName = (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api");
|
||||||
|
}
|
||||||
|
|
||||||
// Save to DB
|
// Save to DB
|
||||||
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
|
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
|
||||||
RunId: runId,
|
RunId: runId,
|
||||||
DatasetName: "data_feed",
|
DatasetName: "data_feed",
|
||||||
Ticker: ticker,
|
Ticker: ticker,
|
||||||
SourceName: (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api"),
|
SourceName: sourceName,
|
||||||
PayloadJson: JsonSerializer.Serialize(normalized),
|
PayloadJson: JsonSerializer.Serialize(normalized),
|
||||||
CapturedAt: DataNormalizationHelper.KstNowIso()
|
CapturedAt: DataNormalizationHelper.KstNowIso()
|
||||||
));
|
));
|
||||||
|
|
||||||
// Track source
|
// Track source
|
||||||
var source = (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api");
|
if (!sourceCounts.ContainsKey(sourceName))
|
||||||
if (!sourceCounts.ContainsKey(source))
|
sourceCounts[sourceName] = 0;
|
||||||
sourceCounts[source] = 0;
|
sourceCounts[sourceName]++;
|
||||||
sourceCounts[source]++;
|
|
||||||
|
|
||||||
rows.Add(normalized);
|
rows.Add(normalized);
|
||||||
result.SuccessCount++;
|
result.SuccessCount++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogWarning(ex, "Collection failed for {Ticker} (run {RunId})", ticker, runId);
|
||||||
result.ErrorCount++;
|
result.ErrorCount++;
|
||||||
errors.Add(new Dictionary<string, object>
|
errors.Add(new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -116,33 +145,122 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
TotalErrors: result.ErrorCount
|
TotalErrors: result.ErrorCount
|
||||||
));
|
));
|
||||||
|
|
||||||
// Output JSON file
|
// Determine gate status
|
||||||
var outputPath = Path.Combine(Path.GetTempPath(), "kis_data_collection_v1.json");
|
var gate = result.SuccessCount == 0 ? "FAIL"
|
||||||
|
: result.ErrorCount == 0 ? "PASS"
|
||||||
|
: result.ErrorCount < result.SuccessCount * 0.1 ? "PASS"
|
||||||
|
: "PASS_WITH_WARNINGS";
|
||||||
|
|
||||||
|
// Output JSON file to <repo>/Temp/kis_dotnet_collection_v1.json
|
||||||
|
var outputPath = GetOutputPath();
|
||||||
var outputData = new
|
var outputData = new
|
||||||
{
|
{
|
||||||
formula_id = "KIS_DATA_COLLECTION_V1",
|
formula_id = "KIS_DOTNET_COLLECTION_V1",
|
||||||
|
gate = gate,
|
||||||
run_id = runId,
|
run_id = runId,
|
||||||
started_at = startedAt,
|
started_at = startedAt,
|
||||||
finished_at = finishedAt,
|
finished_at = finishedAt,
|
||||||
row_count = rows.Count,
|
summary = new
|
||||||
source_counts = sourceCounts,
|
{
|
||||||
errors = errors,
|
success_count = result.SuccessCount,
|
||||||
rows = rows
|
error_count = result.ErrorCount,
|
||||||
|
source_counts = sourceCounts
|
||||||
|
}
|
||||||
};
|
};
|
||||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true }));
|
File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
// Log: skipped
|
LogLineageEvent(runId, result.Status, result.SuccessCount, result.ErrorCount);
|
||||||
|
|
||||||
|
_logger.LogInformation("Collection run {RunId} finished with status {Status}: {Success} ok, {Errors} errors",
|
||||||
|
runId, result.Status, result.SuccessCount, result.ErrorCount);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogError(ex, "Collection run {RunId} failed with exception", runId);
|
||||||
result.Status = "FAILED";
|
result.Status = "FAILED";
|
||||||
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
||||||
result.ErrorMessage = ex.Message;
|
result.ErrorMessage = ex.Message;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetOutputPath()
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var current = new DirectoryInfo(baseDir);
|
||||||
|
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(current.FullName, ".git"))
|
||||||
|
|| File.Exists(Path.Combine(current.FullName, "GatherTradingData.json")))
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(current.FullName, "Temp");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
return Path.Combine(tempDir, "kis_dotnet_collection_v1.json");
|
||||||
|
}
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(Path.GetTempPath(), "kis_dotnet_collection_v1.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMarketClosed()
|
||||||
|
{
|
||||||
|
// KST Time conversion (UTC+9)
|
||||||
|
var kst = DateTime.UtcNow.AddHours(9);
|
||||||
|
|
||||||
|
// Weekend check
|
||||||
|
if (kst.DayOfWeek == DayOfWeek.Saturday || kst.DayOfWeek == DayOfWeek.Sunday)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Market hours check (09:00 - 15:30)
|
||||||
|
var time = kst.TimeOfDay;
|
||||||
|
if (time < new TimeSpan(9, 0, 0) || time > new TimeSpan(15, 30, 0))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogLineageEvent(string runId, string status, int successCount, int errorCount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var current = new DirectoryInfo(baseDir);
|
||||||
|
string? repoRoot = null;
|
||||||
|
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||||
|
{
|
||||||
|
repoRoot = current.FullName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repoRoot != null)
|
||||||
|
{
|
||||||
|
var runtimeDir = Path.Combine(repoRoot, "runtime");
|
||||||
|
Directory.CreateDirectory(runtimeDir);
|
||||||
|
var lineagePath = Path.Combine(runtimeDir, "lineage_events.jsonl");
|
||||||
|
|
||||||
|
var ev = new
|
||||||
|
{
|
||||||
|
@event = "collection_run_completed",
|
||||||
|
run_id = runId,
|
||||||
|
status = status,
|
||||||
|
success_count = successCount,
|
||||||
|
error_count = errorCount,
|
||||||
|
timestamp = DataNormalizationHelper.KstNowIso()
|
||||||
|
};
|
||||||
|
|
||||||
|
File.AppendAllText(lineagePath, JsonSerializer.Serialize(ev) + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* Robust fallback */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace QuantEngine.Application.Services;
|
||||||
|
|
||||||
|
public sealed class LearningDatasetService
|
||||||
|
{
|
||||||
|
private readonly ILearningDatasetReader _reader;
|
||||||
|
|
||||||
|
public LearningDatasetService(ILearningDatasetReader reader) => _reader = reader;
|
||||||
|
|
||||||
|
public async Task<string> ExportJsonAsync(string outputPath, int limit = 1000)
|
||||||
|
{
|
||||||
|
var rows = await _reader.ReadTrainingExamplesAsync(limit);
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
formula_id = "ENGINE_HISTORY_TRAINING_DATASET_V1",
|
||||||
|
gate = rows.Count > 0 ? "PASS" : "DATA_MISSING",
|
||||||
|
generated_at = DateTimeOffset.UtcNow,
|
||||||
|
sample_count = rows.Count,
|
||||||
|
source = "engine_history.training_example_v1",
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
var path = Path.GetFullPath(outputPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,8 @@ namespace QuantEngine.Application.Services
|
|||||||
result.TotalElapsedMilliseconds = totalSw.Elapsed.TotalMilliseconds;
|
result.TotalElapsedMilliseconds = totalSw.Elapsed.TotalMilliseconds;
|
||||||
|
|
||||||
// Output JSON file for integration validation
|
// Output JSON file for integration validation
|
||||||
var tempDir = @"C:\Temp\data_feed\Temp";
|
var tempDir = Environment.GetEnvironmentVariable("QE_TEMP_ROOT")
|
||||||
|
?? Path.Combine(Directory.GetCurrentDirectory(), "Temp");
|
||||||
if (!Directory.Exists(tempDir))
|
if (!Directory.Exists(tempDir))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(tempDir);
|
Directory.CreateDirectory(tempDir);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "2026-05-24-operational-report-v1",
|
||||||
|
"source_json": "GatherTradingData.json",
|
||||||
|
"generated_at": "2026-07-12T00:00:00+00:00",
|
||||||
|
"section_count": 38,
|
||||||
|
"sections": [
|
||||||
|
{ "name": "exec_safety_declaration", "title": "Execution Safety", "markdown": "source: .NET operational report builder" },
|
||||||
|
{ "name": "portfolio_health", "title": "Portfolio Health", "markdown": "fixture" },
|
||||||
|
{ "name": "factor_evidence", "title": "Factor Evidence", "markdown": "fixture" },
|
||||||
|
{ "name": "decision_ledger", "title": "Decision Ledger", "markdown": "fixture" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using QuantEngine.Core.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class FormulaCanonicalCoverageTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ActiveFormulaImplementationsReturnTheirCanonicalIds()
|
||||||
|
{
|
||||||
|
var input = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["velocity_1d"] = 0.01d, ["velocity_threshold"] = 0.02d,
|
||||||
|
["cash_shortfall_krw"] = 100d, ["recovered_krw"] = 100d,
|
||||||
|
["proposal_gate"] = "PASS", ["cycle_detected"] = false,
|
||||||
|
["intraday_restriction_gate"] = "PASS", ["portfolio_health_label"] = "HEALTHY",
|
||||||
|
["rs_v2_score"] = 1d, ["technical_score"] = 1d,
|
||||||
|
["current_price"] = 90d, ["stop_loss_price"] = 100d, ["gap_threshold"] = 0.05d,
|
||||||
|
["price"] = 10_000d,
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = new[]
|
||||||
|
{
|
||||||
|
FormulaCanonicalCoverage.AntiChaseV1(input),
|
||||||
|
FormulaCanonicalCoverage.CashRecoveryV1(input),
|
||||||
|
FormulaCanonicalCoverage.ComprehensiveProposalV1(input),
|
||||||
|
FormulaCanonicalCoverage.DfgV1(input),
|
||||||
|
FormulaCanonicalCoverage.IntradayV1(input),
|
||||||
|
FormulaCanonicalCoverage.PortfolioHealthV1(input),
|
||||||
|
FormulaCanonicalCoverage.RsV2Fusion(input),
|
||||||
|
FormulaCanonicalCoverage.StopBreachV1(input),
|
||||||
|
FormulaCanonicalCoverage.TickNormV1(input),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(9, results.Length);
|
||||||
|
Assert.All(results, result => Assert.NotEqual("DATA_MISSING — 하네스 업데이트 필요", result["gate"]));
|
||||||
|
Assert.Equal(9, results.Select(result => result["formula_id"]).Distinct().Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ namespace QuantEngine.Core.Tests
|
|||||||
Assert.True(step.ElapsedMilliseconds > 0);
|
Assert.True(step.ElapsedMilliseconds > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var expectedJsonPath = @"C:\Temp\data_feed\Temp\dotnet_pipeline_e2e_v1.json";
|
var tempRoot = Environment.GetEnvironmentVariable("QE_TEMP_ROOT")
|
||||||
|
?? Path.Combine(Directory.GetCurrentDirectory(), "Temp");
|
||||||
|
var expectedJsonPath = Path.Combine(tempRoot, "dotnet_pipeline_e2e_v1.json");
|
||||||
Assert.True(File.Exists(expectedJsonPath));
|
Assert.True(File.Exists(expectedJsonPath));
|
||||||
|
|
||||||
var jsonContent = File.ReadAllText(expectedJsonPath);
|
var jsonContent = File.ReadAllText(expectedJsonPath);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
|
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -22,6 +24,10 @@
|
|||||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||||
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
||||||
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
|
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\QuantEngine.Web\QuantEngine.Web.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Fixtures\operational_report.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.Common;
|
||||||
|
using QuantEngine.Web.Services;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public class SchedulerServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InitializeSchedules_RegistersAllFourRequiredJobs()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var loggerMock = new Mock<ILogger<SchedulerService>>();
|
||||||
|
var jobClientMock = new Mock<IBackgroundJobClient>();
|
||||||
|
var recurringJobManagerMock = new Mock<IRecurringJobManager>();
|
||||||
|
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
|
||||||
|
|
||||||
|
var configMock = new Mock<IConfiguration>();
|
||||||
|
configMock.Setup(c => c["Kis:AccountMode"]).Returns("mock");
|
||||||
|
|
||||||
|
var service = new SchedulerService(
|
||||||
|
loggerMock.Object,
|
||||||
|
jobClientMock.Object,
|
||||||
|
recurringJobManagerMock.Object,
|
||||||
|
scopeFactoryMock.Object,
|
||||||
|
configMock.Object
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
service.InitializeSchedules();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Verify daily-collection was added or updated
|
||||||
|
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||||
|
"daily-collection",
|
||||||
|
It.IsAny<Job>(),
|
||||||
|
"0 9 * * *",
|
||||||
|
It.IsAny<RecurringJobOptions>()
|
||||||
|
), Times.Once);
|
||||||
|
|
||||||
|
// Verify hourly-price-update was added or updated
|
||||||
|
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||||
|
"hourly-price-update",
|
||||||
|
It.IsAny<Job>(),
|
||||||
|
"0 9,11,13,15 * * 1-5",
|
||||||
|
It.IsAny<RecurringJobOptions>()
|
||||||
|
), Times.Once);
|
||||||
|
|
||||||
|
// Verify weekly-report was added or updated
|
||||||
|
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||||
|
"weekly-report",
|
||||||
|
It.IsAny<Job>(),
|
||||||
|
"0 17 * * 5",
|
||||||
|
It.IsAny<RecurringJobOptions>()
|
||||||
|
), Times.Once);
|
||||||
|
|
||||||
|
// Verify monthly-optimization was added or updated
|
||||||
|
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||||
|
"monthly-optimization",
|
||||||
|
It.IsAny<Job>(),
|
||||||
|
"0 2 1 * *",
|
||||||
|
It.IsAny<RecurringJobOptions>()
|
||||||
|
), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ public class UnitTest1
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void OperationalReportLoader_ParsesCanonicalTempReport()
|
public void OperationalReportLoader_ParsesCanonicalTempReport()
|
||||||
{
|
{
|
||||||
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "Temp", "operational_report.json"));
|
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "operational_report.json");
|
||||||
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
||||||
|
|
||||||
Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion);
|
Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical .NET implementations for formula IDs that were previously only
|
||||||
|
/// represented by legacy harness anchors. Inputs are supplied by the harness;
|
||||||
|
/// missing inputs produce DATA_MISSING rather than invented values.
|
||||||
|
/// </summary>
|
||||||
|
public static class FormulaCanonicalCoverage
|
||||||
|
{
|
||||||
|
public static Dictionary<string, object?> AntiChaseV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
{
|
||||||
|
var velocity = Number(input, "velocity_1d");
|
||||||
|
var threshold = Number(input, "velocity_threshold");
|
||||||
|
if (!velocity.HasValue || !threshold.HasValue)
|
||||||
|
return Missing("ANTI_CHASE_V1");
|
||||||
|
return Result("ANTI_CHASE_V1", velocity.Value > threshold.Value ? "BLOCK" : "PASS", velocity.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> CashRecoveryV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
{
|
||||||
|
var shortfall = Number(input, "cash_shortfall_krw");
|
||||||
|
var recovered = Number(input, "recovered_krw");
|
||||||
|
if (!shortfall.HasValue || !recovered.HasValue)
|
||||||
|
return Missing("CASH_RECOVERY_V1");
|
||||||
|
return Result("CASH_RECOVERY_V1", recovered.Value >= shortfall.Value ? "PASS" : "LIMITED", recovered.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> ComprehensiveProposalV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("COMPREHENSIVE_PROPOSAL_V1", input, "proposal_gate");
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> DfgV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("DFG_V1", input, "cycle_detected", invert: true);
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> IntradayV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("INTRADAY_V1", input, "intraday_restriction_gate");
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> PortfolioHealthV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("PORTFOLIO_HEALTH_V1", input, "portfolio_health_label");
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> RsV2Fusion(IReadOnlyDictionary<string, object?> input)
|
||||||
|
{
|
||||||
|
var rs = Number(input, "rs_v2_score");
|
||||||
|
var technical = Number(input, "technical_score");
|
||||||
|
if (!rs.HasValue || !technical.HasValue)
|
||||||
|
return Missing("RS_V2_FUSION");
|
||||||
|
var score = (rs.Value + technical.Value) / 2d;
|
||||||
|
return Result("RS_V2_FUSION", score >= 0 ? "PASS" : "BLOCK", score);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> StopBreachV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
{
|
||||||
|
var current = Number(input, "current_price");
|
||||||
|
var stop = Number(input, "stop_loss_price");
|
||||||
|
var gap = Number(input, "gap_threshold");
|
||||||
|
if (!current.HasValue || !stop.HasValue || !gap.HasValue || stop.Value == 0)
|
||||||
|
return Missing("STOP_BREACH_V1");
|
||||||
|
var gapPct = (stop.Value - current.Value) / stop.Value;
|
||||||
|
return Result("STOP_BREACH_V1", gapPct >= gap.Value ? "BREACH_IMMEDIATE_EXIT" : "PASS", gapPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> TickNormV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
{
|
||||||
|
var price = Number(input, "price");
|
||||||
|
if (!price.HasValue)
|
||||||
|
return Missing("TICK_NORM_V1");
|
||||||
|
return Result("TICK_NORM_V1", "PASS", KrxTickNormalizer.NormalizeTick(price.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> GateFromInputs(string id, IReadOnlyDictionary<string, object?> input, string field, bool invert = false)
|
||||||
|
{
|
||||||
|
if (!input.TryGetValue(field, out var value) || value is null)
|
||||||
|
return Missing(id);
|
||||||
|
var blocked = string.Equals(value.ToString(), "BLOCK", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value.ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (invert) blocked = !blocked;
|
||||||
|
return Result(id, blocked ? "BLOCK" : "PASS", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> Missing(string id) => new()
|
||||||
|
{
|
||||||
|
["formula_id"] = id,
|
||||||
|
["gate"] = "DATA_MISSING — 하네스 업데이트 필요",
|
||||||
|
["value"] = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> Result(string id, string gate, double? value) => new()
|
||||||
|
{
|
||||||
|
["formula_id"] = id,
|
||||||
|
["gate"] = gate,
|
||||||
|
["value"] = value,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static double? Number(IReadOnlyDictionary<string, object?> input, string key)
|
||||||
|
{
|
||||||
|
if (!input.TryGetValue(key, out var value) || value is null)
|
||||||
|
return null;
|
||||||
|
return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
public interface ILearningDatasetReader
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<IDictionary<string, object?>>> ReadTrainingExamplesAsync(int limit = 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
namespace QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
public interface INormalizedLearningStore
|
||||||
|
{
|
||||||
|
Task<Guid> AppendSourceObservationAsync(SourceObservationRecord record);
|
||||||
|
Task<Guid> AppendFactorObservationAsync(FactorObservationRecord record);
|
||||||
|
Task<Guid> AppendDecisionAsync(DecisionEventRecord record);
|
||||||
|
Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role);
|
||||||
|
Task AppendOutcomeAsync(OutcomeEvaluationRecord record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SourceObservationRecord(
|
||||||
|
DateTimeOffset ObservedAt,
|
||||||
|
string InstrumentId,
|
||||||
|
string SourceName,
|
||||||
|
string SourceVersion,
|
||||||
|
string PayloadJson,
|
||||||
|
string ProvenanceJson);
|
||||||
|
|
||||||
|
public sealed record FactorObservationRecord(
|
||||||
|
Guid ObservationId,
|
||||||
|
Guid FactorObservationId,
|
||||||
|
string FactorId,
|
||||||
|
string FactorVersion,
|
||||||
|
DateTimeOffset ObservedAt,
|
||||||
|
decimal? NumericValue,
|
||||||
|
string? TextValue,
|
||||||
|
string Gate,
|
||||||
|
string ProvenanceJson);
|
||||||
|
|
||||||
|
public sealed record DecisionEventRecord(
|
||||||
|
string DecisionKey,
|
||||||
|
DateTimeOffset DecidedAt,
|
||||||
|
string InstrumentId,
|
||||||
|
string Action,
|
||||||
|
string Gate,
|
||||||
|
decimal? Score,
|
||||||
|
string SourceVersion,
|
||||||
|
string TraceJson,
|
||||||
|
string ProvenanceJson);
|
||||||
|
|
||||||
|
public sealed record OutcomeEvaluationRecord(
|
||||||
|
Guid DecisionId,
|
||||||
|
int HorizonDays,
|
||||||
|
DateTimeOffset EvaluatedAt,
|
||||||
|
decimal? RealizedReturn,
|
||||||
|
decimal? BenchmarkReturn,
|
||||||
|
decimal? ExcessReturn,
|
||||||
|
string OutcomeClass,
|
||||||
|
string EvaluationGate,
|
||||||
|
string ProvenanceJson);
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
-- V5__Add_Normalized_Learning_History.sql
|
||||||
|
-- Normalized PostgreSQL event store for factor decisions and learning data.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS engine_history;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engine_history.source_observation (
|
||||||
|
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
instrument_id TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
source_version TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_source_observation_instrument_time
|
||||||
|
ON engine_history.source_observation (instrument_id, observed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engine_history.factor_definition (
|
||||||
|
factor_id TEXT NOT NULL,
|
||||||
|
factor_version TEXT NOT NULL,
|
||||||
|
formula_id TEXT NOT NULL,
|
||||||
|
effective_from TIMESTAMPTZ NOT NULL,
|
||||||
|
effective_to TIMESTAMPTZ,
|
||||||
|
definition JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (factor_id, factor_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engine_history.factor_observation (
|
||||||
|
factor_observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
observation_id UUID NOT NULL REFERENCES engine_history.source_observation(observation_id),
|
||||||
|
factor_id TEXT NOT NULL,
|
||||||
|
factor_version TEXT NOT NULL,
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
numeric_value NUMERIC,
|
||||||
|
text_value TEXT,
|
||||||
|
gate TEXT NOT NULL,
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
CONSTRAINT fk_factor_definition
|
||||||
|
FOREIGN KEY (factor_id, factor_version)
|
||||||
|
REFERENCES engine_history.factor_definition(factor_id, factor_version),
|
||||||
|
CONSTRAINT factor_value_present CHECK (numeric_value IS NOT NULL OR text_value IS NOT NULL)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_factor_observation_factor_time
|
||||||
|
ON engine_history.factor_observation (factor_id, observed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engine_history.decision_event (
|
||||||
|
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
decision_key TEXT NOT NULL UNIQUE,
|
||||||
|
decided_at TIMESTAMPTZ NOT NULL,
|
||||||
|
instrument_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
gate TEXT NOT NULL,
|
||||||
|
score NUMERIC,
|
||||||
|
source_version TEXT NOT NULL,
|
||||||
|
trace JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_decision_event_instrument_time
|
||||||
|
ON engine_history.decision_event (instrument_id, decided_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engine_history.decision_factor_evidence (
|
||||||
|
decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id),
|
||||||
|
factor_observation_id UUID NOT NULL REFERENCES engine_history.factor_observation(factor_observation_id),
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (decision_id, factor_observation_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engine_history.outcome_evaluation (
|
||||||
|
evaluation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id),
|
||||||
|
horizon_days INTEGER NOT NULL CHECK (horizon_days > 0),
|
||||||
|
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
realized_return NUMERIC,
|
||||||
|
benchmark_return NUMERIC,
|
||||||
|
excess_return NUMERIC,
|
||||||
|
outcome_class TEXT NOT NULL,
|
||||||
|
evaluation_gate TEXT NOT NULL,
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (decision_id, horizon_days)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Read-optimized projection for model training and calibration jobs.
|
||||||
|
CREATE OR REPLACE VIEW engine_history.training_example_v1 AS
|
||||||
|
SELECT
|
||||||
|
d.decision_id,
|
||||||
|
d.decision_key,
|
||||||
|
d.decided_at,
|
||||||
|
d.instrument_id,
|
||||||
|
d.action,
|
||||||
|
d.gate AS decision_gate,
|
||||||
|
d.score,
|
||||||
|
d.source_version,
|
||||||
|
e.horizon_days,
|
||||||
|
e.realized_return,
|
||||||
|
e.benchmark_return,
|
||||||
|
e.excess_return,
|
||||||
|
e.outcome_class,
|
||||||
|
e.evaluation_gate,
|
||||||
|
jsonb_agg(jsonb_build_object(
|
||||||
|
'factor_id', f.factor_id,
|
||||||
|
'factor_version', f.factor_version,
|
||||||
|
'numeric_value', f.numeric_value,
|
||||||
|
'text_value', f.text_value,
|
||||||
|
'gate', f.gate,
|
||||||
|
'role', evidence.role
|
||||||
|
) ORDER BY f.factor_id) AS factor_features
|
||||||
|
FROM engine_history.decision_event d
|
||||||
|
JOIN engine_history.outcome_evaluation e ON e.decision_id = d.decision_id
|
||||||
|
JOIN engine_history.decision_factor_evidence evidence ON evidence.decision_id = d.decision_id
|
||||||
|
JOIN engine_history.factor_observation f ON f.factor_observation_id = evidence.factor_observation_id
|
||||||
|
GROUP BY d.decision_id, d.decision_key, d.decided_at, d.instrument_id, d.action,
|
||||||
|
d.gate, d.score, d.source_version, e.horizon_days, e.realized_return,
|
||||||
|
e.benchmark_return, e.excess_return, e.outcome_class, e.evaluation_gate;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- V6__Add_Market_Time_Series.sql
|
||||||
|
-- Canonical PostgreSQL daily series for point-in-time factor calculations.
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS quantengine;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quantengine.price_history_daily (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
trade_date DATE NOT NULL,
|
||||||
|
open NUMERIC NOT NULL,
|
||||||
|
high NUMERIC NOT NULL,
|
||||||
|
low NUMERIC NOT NULL,
|
||||||
|
close NUMERIC NOT NULL,
|
||||||
|
volume BIGINT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (ticker, trade_date),
|
||||||
|
CONSTRAINT price_history_daily_ohlc_order CHECK (high >= low AND high >= open AND high >= close AND low <= open AND low <= close),
|
||||||
|
CONSTRAINT price_history_daily_volume_nonnegative CHECK (volume >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_price_history_daily_date
|
||||||
|
ON quantengine.price_history_daily (trade_date DESC, ticker);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quantengine.macro_history_daily (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
trade_date DATE NOT NULL,
|
||||||
|
value NUMERIC NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (symbol, trade_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_macro_history_daily_date
|
||||||
|
ON quantengine.macro_history_daily (trade_date DESC, symbol);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- V7__Seed_Initial_Settings.sql
|
||||||
|
-- Insert default system settings into quantengine.settings
|
||||||
|
|
||||||
|
INSERT INTO quantengine.settings (ordinal, key, value_json, note, updated_at)
|
||||||
|
VALUES
|
||||||
|
(1, 'api_request_interval_ms', '{"value": 400}', 'KIS OpenAPI 요청 간격 딜레이 (밀리초)', NOW()::text),
|
||||||
|
(2, 'ip_lockout_duration_seconds', '{"value": 1800}', '비밀번호 실패 시 IP 차단 지속 시간 (초)', NOW()::text),
|
||||||
|
(3, 'max_login_attempts', '{"value": 3}', '로그인 잠금 전 최대 시도 가능 횟수', NOW()::text)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Dapper;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace QuantEngine.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public sealed class LearningDatasetReader : ILearningDatasetReader
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public LearningDatasetReader(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IDictionary<string, object?>>> ReadTrainingExamplesAsync(int limit = 1000)
|
||||||
|
{
|
||||||
|
if (limit is < 1 or > 10000)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||||
|
|
||||||
|
using var connection = _connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync(
|
||||||
|
"SELECT * FROM engine_history.training_example_v1 ORDER BY decided_at DESC LIMIT @Limit",
|
||||||
|
new { Limit = limit });
|
||||||
|
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Dapper;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace QuantEngine.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public sealed class NormalizedLearningStore : INormalizedLearningStore
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public NormalizedLearningStore(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory;
|
||||||
|
|
||||||
|
public async Task<Guid> AppendSourceObservationAsync(SourceObservationRecord record)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
await conn.ExecuteAsync(@"INSERT INTO engine_history.source_observation
|
||||||
|
(observation_id, observed_at, instrument_id, source_name, source_version, payload, provenance)
|
||||||
|
VALUES (@Id, @ObservedAt, @InstrumentId, @SourceName, @SourceVersion,
|
||||||
|
CAST(@PayloadJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.ObservedAt, record.InstrumentId, record.SourceName, record.SourceVersion, record.PayloadJson, record.ProvenanceJson });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> AppendFactorObservationAsync(FactorObservationRecord record)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
await conn.ExecuteAsync(@"INSERT INTO engine_history.factor_observation
|
||||||
|
(factor_observation_id, observation_id, factor_id, factor_version, observed_at,
|
||||||
|
numeric_value, text_value, gate, provenance)
|
||||||
|
VALUES (@FactorObservationId, @ObservationId, @FactorId, @FactorVersion, @ObservedAt,
|
||||||
|
@NumericValue, @TextValue, @Gate, CAST(@ProvenanceJson AS jsonb))", new
|
||||||
|
{
|
||||||
|
record.FactorObservationId,
|
||||||
|
record.ObservationId,
|
||||||
|
record.FactorId,
|
||||||
|
record.FactorVersion,
|
||||||
|
record.ObservedAt,
|
||||||
|
record.NumericValue,
|
||||||
|
record.TextValue,
|
||||||
|
record.Gate,
|
||||||
|
record.ProvenanceJson
|
||||||
|
});
|
||||||
|
return record.FactorObservationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> AppendDecisionAsync(DecisionEventRecord record)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_event
|
||||||
|
(decision_id, decision_key, decided_at, instrument_id, action, gate, score,
|
||||||
|
source_version, trace, provenance)
|
||||||
|
VALUES (@Id, @DecisionKey, @DecidedAt, @InstrumentId, @Action, @Gate, @Score,
|
||||||
|
@SourceVersion, CAST(@TraceJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.DecisionKey, record.DecidedAt, record.InstrumentId, record.Action, record.Gate, record.Score, record.SourceVersion, record.TraceJson, record.ProvenanceJson });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_factor_evidence
|
||||||
|
(decision_id, factor_observation_id, role) VALUES (@DecisionId, @FactorObservationId, @Role)", new { decisionId, factorObservationId, role });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AppendOutcomeAsync(OutcomeEvaluationRecord record)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
await conn.ExecuteAsync(@"INSERT INTO engine_history.outcome_evaluation
|
||||||
|
(decision_id, horizon_days, evaluated_at, realized_return, benchmark_return,
|
||||||
|
excess_return, outcome_class, evaluation_gate, provenance)
|
||||||
|
VALUES (@DecisionId, @HorizonDays, @EvaluatedAt, @RealizedReturn, @BenchmarkReturn,
|
||||||
|
@ExcessReturn, @OutcomeClass, @EvaluationGate, CAST(@ProvenanceJson AS jsonb))
|
||||||
|
ON CONFLICT (decision_id, horizon_days) DO UPDATE SET
|
||||||
|
evaluated_at = EXCLUDED.evaluated_at,
|
||||||
|
realized_return = EXCLUDED.realized_return,
|
||||||
|
benchmark_return = EXCLUDED.benchmark_return,
|
||||||
|
excess_return = EXCLUDED.excess_return,
|
||||||
|
outcome_class = EXCLUDED.outcome_class,
|
||||||
|
evaluation_gate = EXCLUDED.evaluation_gate,
|
||||||
|
provenance = EXCLUDED.provenance", record);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
@@ -29,6 +31,9 @@ public class KisApiClient : IKisApiClient
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ITokenCache _tokenCache;
|
private readonly ITokenCache _tokenCache;
|
||||||
private readonly ILogger<KisApiClient> _logger;
|
private readonly ILogger<KisApiClient> _logger;
|
||||||
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
|
||||||
|
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
||||||
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||||
|
|
||||||
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
|
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
|
||||||
{
|
{
|
||||||
@@ -138,61 +143,104 @@ public class KisApiClient : IKisApiClient
|
|||||||
if (!string.IsNullOrEmpty(queryString))
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
url += $"?{queryString}";
|
url += $"?{queryString}";
|
||||||
|
|
||||||
try
|
int maxAttempts = 3;
|
||||||
{
|
int delayMs = 1000;
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
||||||
foreach (var header in headers)
|
|
||||||
request.Headers.Add(header.Key, header.Value);
|
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
|
||||||
return result ?? new Dictionary<string, object>();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "KIS request failed: {Path} / {TrId}", path, trId);
|
try
|
||||||
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId}.", ex);
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
foreach (var header in headers)
|
||||||
|
request.Headers.Add(header.Key, header.Value);
|
||||||
|
|
||||||
|
await ApplyRateLimitDelayAsync(account);
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||||
|
return result ?? new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (attempt < maxAttempts)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "KIS request failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs);
|
||||||
|
await Task.Delay(delayMs);
|
||||||
|
delayMs *= 2;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "KIS request failed after {MaxAttempts} attempts: {Path} / {TrId}", maxAttempts, path, trId);
|
||||||
|
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId} after {maxAttempts} attempts.", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Unreachable code in KIS client SendRequestAsync");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
|
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
|
||||||
{
|
{
|
||||||
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
|
var tokenLock = TokenLocks.GetOrAdd(creds.Account, _ => new SemaphoreSlim(1, 1));
|
||||||
if (!string.IsNullOrEmpty(cachedToken))
|
await tokenLock.WaitAsync();
|
||||||
return cachedToken;
|
|
||||||
|
|
||||||
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _httpClient.PostAsJsonAsync(
|
// Re-check after acquiring the account lock. Another request may
|
||||||
$"{creds.Domain}/oauth2/tokenP",
|
// have refreshed the shared cache while this request was waiting.
|
||||||
tokenRequest
|
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
|
||||||
);
|
if (!string.IsNullOrEmpty(cachedToken))
|
||||||
response.EnsureSuccessStatusCode();
|
return cachedToken;
|
||||||
|
|
||||||
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
|
||||||
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
|
|
||||||
|
|
||||||
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
|
int maxAttempts = 3;
|
||||||
throw new InvalidOperationException("No access_token in response");
|
int delayMs = 1000;
|
||||||
var accessToken = tokenObj.ToString()!;
|
|
||||||
|
|
||||||
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null
|
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||||
? expiresObj.ToString()
|
{
|
||||||
: "86400";
|
try
|
||||||
var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400;
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
await ApplyRateLimitDelayAsync(creds.Account);
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(
|
||||||
|
$"{creds.Domain}/oauth2/tokenP",
|
||||||
|
tokenRequest
|
||||||
|
);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
|
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||||
return accessToken;
|
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
|
||||||
|
|
||||||
|
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
|
||||||
|
throw new InvalidOperationException("No access_token in response");
|
||||||
|
var accessToken = tokenObj.ToString()!;
|
||||||
|
|
||||||
|
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null
|
||||||
|
? expiresObj.ToString()
|
||||||
|
: "86400";
|
||||||
|
var expiresInSec = int.TryParse(expiresInStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
|
||||||
|
? seconds
|
||||||
|
: 86400;
|
||||||
|
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
||||||
|
|
||||||
|
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
|
||||||
|
_logger.LogInformation("KIS token refreshed for {Account}; expires at {ExpiresAtUtc}", creds.Account, expiresAt);
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (attempt < maxAttempts)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "KIS token refresh failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs);
|
||||||
|
await Task.Delay(delayMs);
|
||||||
|
delayMs *= 2;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "KIS token refresh failed after {MaxAttempts} attempts", maxAttempts);
|
||||||
|
throw new InvalidOperationException($"KIS token refresh failed after {maxAttempts} attempts; check credentials and API availability.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Unreachable code in KIS client TokenRefresh");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
finally
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "KIS token refresh failed");
|
tokenLock.Release();
|
||||||
throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,4 +322,29 @@ public class KisApiClient : IKisApiClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyRateLimitDelayAsync(string account)
|
||||||
|
{
|
||||||
|
await _rateLimitSemaphore.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mode = account.Contains("real", StringComparison.OrdinalIgnoreCase) ? "real" : "mock";
|
||||||
|
int minIntervalMs = mode == "real" ? 150 : 400;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var elapsed = (now - _lastRequestTime).TotalMilliseconds;
|
||||||
|
if (elapsed < minIntervalMs)
|
||||||
|
{
|
||||||
|
var delay = minIntervalMs - (int)elapsed;
|
||||||
|
_logger.LogDebug("Rate limit throttling: delaying for {Delay}ms (Mode: {Mode})", delay, mode);
|
||||||
|
await Task.Delay(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRequestTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_rateLimitSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
@@ -31,7 +32,11 @@ namespace QuantEngine.Infrastructure.Services
|
|||||||
if (token == null)
|
if (token == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var expiresAt = DateTime.Parse(token.ExpiresAt);
|
DateTime expiresAt;
|
||||||
|
if (!DateTime.TryParse((string)token.ExpiresAt, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||||
|
out expiresAt))
|
||||||
|
return null;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);
|
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);
|
||||||
|
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
using Bunit;
|
|
||||||
using MudBlazor;
|
|
||||||
using Xunit;
|
|
||||||
using QuantEngine.Web.Client.Pages;
|
|
||||||
using QuantEngine.Web.Client.Components;
|
|
||||||
|
|
||||||
namespace QuantEngine.Web.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for Dashboard component using bUnit
|
|
||||||
/// </summary>
|
|
||||||
public class DashboardComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Renders_Without_Errors()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("관리자 대시보드");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Displays_KPI_Cards()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert - Should have 4 KPI cards
|
|
||||||
cut.FindAll(".mud-paper").Count.Should().BeGreaterThanOrEqualTo(4);
|
|
||||||
cut.Markup.Should().Contain("총 수집 실행");
|
|
||||||
cut.Markup.Should().Contain("성공률");
|
|
||||||
cut.Markup.Should().Contain("최근 에러");
|
|
||||||
cut.Markup.Should().Contain("마지막 동기화");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Shows_System_Status()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("시스템 상태");
|
|
||||||
cut.Markup.Should().Contain("API 서버");
|
|
||||||
cut.Markup.Should().Contain("데이터베이스");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Has_Activity_Feed()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("최근 활동");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Has_Collections_Table()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("최근 데이터 수집 실행");
|
|
||||||
cut.Markup.Should().Contain("새로고침");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for FormField component
|
|
||||||
/// </summary>
|
|
||||||
public class FormFieldComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void FormField_Renders_Text_Input()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var parameters = new ComponentParameterCollection
|
|
||||||
{
|
|
||||||
{ "Label", "사용자명" },
|
|
||||||
{ "Type", "text" },
|
|
||||||
{ "Placeholder", "이름 입력" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var cut = RenderComponent<FormField>(parameters);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("사용자명");
|
|
||||||
cut.Markup.Should().Contain("이름 입력");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FormField_Shows_Required_Indicator()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var parameters = new ComponentParameterCollection
|
|
||||||
{
|
|
||||||
{ "Label", "이메일" },
|
|
||||||
{ "Type", "email" },
|
|
||||||
{ "Required", true }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var cut = RenderComponent<FormField>(parameters);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("*");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FormField_Displays_Error_Message()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var parameters = new ComponentParameterCollection
|
|
||||||
{
|
|
||||||
{ "Label", "비밀번호" },
|
|
||||||
{ "Type", "password" },
|
|
||||||
{ "ErrorMessage", "최소 8자 이상 입력하세요" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var cut = RenderComponent<FormField>(parameters);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("최소 8자 이상 입력하세요");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FormField_Shows_Help_Text()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var parameters = new ComponentParameterCollection
|
|
||||||
{
|
|
||||||
{ "Label", "핸드폰" },
|
|
||||||
{ "Type", "tel" },
|
|
||||||
{ "HelpText", "하이픈 없이 숫자만 입력하세요" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var cut = RenderComponent<FormField>(parameters);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("하이픈 없이 숫자만 입력하세요");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for Portfolio component
|
|
||||||
/// </summary>
|
|
||||||
public class PortfolioComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Renders_Without_Errors()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("포트폴리오");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Displays_Summary_Cards()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// Assert - Should have summary cards
|
|
||||||
cut.Markup.Should().Contain("총 평가액");
|
|
||||||
cut.Markup.Should().Contain("보유 종목");
|
|
||||||
cut.Markup.Should().Contain("수익률");
|
|
||||||
cut.Markup.Should().Contain("위험도");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Shows_Asset_Table()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("자산 구성");
|
|
||||||
cut.Markup.Should().Contain("종목/펀드명");
|
|
||||||
cut.Markup.Should().Contain("평가액");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Shows_Asset_Classification()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("자산 분류");
|
|
||||||
cut.Markup.Should().Contain("대형주");
|
|
||||||
cut.Markup.Should().Contain("중형주");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Shows_Trading_History()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("거래 이력");
|
|
||||||
cut.Markup.Should().Contain("구분");
|
|
||||||
cut.Markup.Should().Contain("금액");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for NavMenu component
|
|
||||||
/// </summary>
|
|
||||||
public class NavMenuComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void NavMenu_Renders_Navigation_Links()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<NavMenu>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("대시보드");
|
|
||||||
cut.Markup.Should().Contain("관리");
|
|
||||||
cut.Markup.Should().Contain("운영");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NavMenu_Has_Admin_Section()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<NavMenu>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("사용자 관리");
|
|
||||||
cut.Markup.Should().Contain("데이터 수집");
|
|
||||||
cut.Markup.Should().Contain("설정");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NavMenu_Has_Help_Section()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<NavMenu>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("도움말");
|
|
||||||
cut.Markup.Should().Contain("문서");
|
|
||||||
cut.Markup.Should().Contain("API");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
@namespace QuantEngine.Web.Client.Components
|
|
||||||
@inject IDialogService DialogService
|
|
||||||
|
|
||||||
@code {
|
|
||||||
public static async Task<bool> Show(IDialogService dialogService, string title, string message, string confirmText = "확인", string cancelText = "취소")
|
|
||||||
{
|
|
||||||
var options = new DialogOptions
|
|
||||||
{
|
|
||||||
CloseButton = false,
|
|
||||||
MaxWidth = MaxWidth.Small,
|
|
||||||
FullWidth = true,
|
|
||||||
BackdropClick = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var parameters = new DialogParameters<ConfirmDialog>
|
|
||||||
{
|
|
||||||
{ x => x.Title, title },
|
|
||||||
{ x => x.Message, message },
|
|
||||||
{ x => x.ConfirmText, confirmText },
|
|
||||||
{ x => x.CancelText, cancelText }
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = await dialogService.ShowAsync<ConfirmDialog>(title, parameters, options);
|
|
||||||
var result = await dialog.Result;
|
|
||||||
|
|
||||||
return !result.Canceled && (bool?)result.Data == true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<MudDialog>
|
|
||||||
<DialogContent>
|
|
||||||
<MudStack Spacing="2">
|
|
||||||
<MudText Typo="Typo.h6">@Title</MudText>
|
|
||||||
<MudText Typo="Typo.body2">@Message</MudText>
|
|
||||||
</MudStack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<MudButton OnClick="Cancel" Color="Color.Default">@CancelText</MudButton>
|
|
||||||
<MudButton OnClick="Confirm" Color="Color.Primary" Variant="Variant.Filled">@ConfirmText</MudButton>
|
|
||||||
</DialogActions>
|
|
||||||
</MudDialog>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[CascadingParameter]
|
|
||||||
private IMudDialogInstance MudDialog { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Title { get; set; } = "확인";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Message { get; set; } = "";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string ConfirmText { get; set; } = "확인";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string CancelText { get; set; } = "취소";
|
|
||||||
|
|
||||||
private void Confirm() => MudDialog.Close(DialogResult.Ok(true));
|
|
||||||
private void Cancel() => MudDialog.Cancel();
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
@namespace QuantEngine.Web.Client.Components
|
|
||||||
|
|
||||||
<MudStack Spacing="2" Class="form-field">
|
|
||||||
<label class="form-label">
|
|
||||||
@Label
|
|
||||||
@if (Required)
|
|
||||||
{
|
|
||||||
<span class="text-error">*</span>
|
|
||||||
}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
@switch (Type)
|
|
||||||
{
|
|
||||||
case "text":
|
|
||||||
case "email":
|
|
||||||
case "password":
|
|
||||||
case "number":
|
|
||||||
<MudTextField T="string"
|
|
||||||
Value="@Value"
|
|
||||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
FullWidth="true"
|
|
||||||
Placeholder="@Placeholder"
|
|
||||||
Type="@Type"
|
|
||||||
Required="@Required"
|
|
||||||
ErrorText="@ErrorMessage" />
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "textarea":
|
|
||||||
<MudTextField T="string"
|
|
||||||
Value="@Value"
|
|
||||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
FullWidth="true"
|
|
||||||
Placeholder="@Placeholder"
|
|
||||||
Lines="5"
|
|
||||||
Required="@Required"
|
|
||||||
ErrorText="@ErrorMessage" />
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "select":
|
|
||||||
<MudSelect T="string"
|
|
||||||
Value="@Value"
|
|
||||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
FullWidth="true"
|
|
||||||
Required="@Required">
|
|
||||||
@foreach (var option in Options)
|
|
||||||
{
|
|
||||||
<MudSelectItem T="string" Value="@option">@option</MudSelectItem>
|
|
||||||
}
|
|
||||||
</MudSelect>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkbox":
|
|
||||||
<MudCheckBox T="bool"
|
|
||||||
Checked="@(Value == "true")"
|
|
||||||
CheckedChanged="@((bool v) => ValueChanged.InvokeAsync(v ? "true" : "false"))">
|
|
||||||
@Label
|
|
||||||
</MudCheckBox>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "date":
|
|
||||||
<MudTextField T="string"
|
|
||||||
Value="@Value"
|
|
||||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
FullWidth="true"
|
|
||||||
Type="date"
|
|
||||||
Required="@Required" />
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(HelpText))
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">@HelpText</MudText>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public string Label { get; set; } = "";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Type { get; set; } = "text";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Value { get; set; } = "";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<string> ValueChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Placeholder { get; set; } = "";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public bool Required { get; set; } = false;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string ErrorMessage { get; set; } = "";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string HelpText { get; set; } = "";
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public List<string> Options { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mud-palette-text-primary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label .text-error {
|
|
||||||
color: var(--mud-palette-error);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
-249
@@ -1,249 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using Microsoft.JSInterop;
|
|
||||||
using QuantEngine.Web.Client.Services;
|
|
||||||
|
|
||||||
namespace QuantEngine.Web.Client.Infrastructure
|
|
||||||
{
|
|
||||||
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|
||||||
{
|
|
||||||
private readonly LocalStorageService _localStorage;
|
|
||||||
private readonly HttpClient _http;
|
|
||||||
private readonly IJSRuntime _jsRuntime;
|
|
||||||
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
|
|
||||||
private const string TokenKey = "quant_admin_access_token";
|
|
||||||
private const string UsernameKey = "quant_admin_username";
|
|
||||||
private const string RoleKey = "quant_admin_role";
|
|
||||||
private const string RememberUsernameKey = "quant_admin_remember_username";
|
|
||||||
|
|
||||||
private AuthenticationState? _cachedState;
|
|
||||||
|
|
||||||
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http, IJSRuntime jsRuntime)
|
|
||||||
{
|
|
||||||
_localStorage = localStorage;
|
|
||||||
_http = http;
|
|
||||||
_jsRuntime = jsRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
||||||
{
|
|
||||||
if (_cachedState != null && _cachedState.User.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Auth] Returning cached authentication state");
|
|
||||||
return _cachedState;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Auth] GetAuthenticationStateAsync called");
|
|
||||||
|
|
||||||
// Primary: Try to validate via /api/auth/me
|
|
||||||
// This works with both cookies (automatic) and Bearer tokens
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Auth] Attempting validation via /api/auth/me (cookie or Bearer)...");
|
|
||||||
// BaseAddress is always set to HostEnvironment.BaseAddress by DI.
|
|
||||||
// Never fall back to a hardcoded port — it breaks in production.
|
|
||||||
var meUrl = "api/auth/me";
|
|
||||||
var requestUri = _http.BaseAddress == null ? new Uri($"http://localhost:5265/{meUrl}") : new Uri(_http.BaseAddress, meUrl);
|
|
||||||
Console.WriteLine($"[Auth] /api/auth/me URL: {requestUri}");
|
|
||||||
|
|
||||||
var meResponse = await _http.GetAsync(requestUri);
|
|
||||||
Console.WriteLine($"[Auth] /api/auth/me status: {meResponse.StatusCode}");
|
|
||||||
|
|
||||||
if (meResponse.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var json = await meResponse.Content.ReadAsStringAsync();
|
|
||||||
Console.WriteLine($"[Auth] Response JSON: {json}");
|
|
||||||
|
|
||||||
var meData = System.Text.Json.JsonDocument.Parse(json).RootElement;
|
|
||||||
var authenticated = meData.TryGetProperty("authenticated", out var authProp) && authProp.GetBoolean();
|
|
||||||
var username = meData.TryGetProperty("username", out var userProp) ? userProp.GetString() : null;
|
|
||||||
var role = meData.TryGetProperty("role", out var roleProp) ? roleProp.GetString() : "Admin";
|
|
||||||
|
|
||||||
Console.WriteLine($"[Auth] Parsed: authenticated={authenticated}, username={username}, role={role}");
|
|
||||||
|
|
||||||
if (authenticated && !string.IsNullOrWhiteSpace(username))
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] ✅ SUCCESS: Authenticated as {username}");
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.Name, username),
|
|
||||||
new Claim(ClaimTypes.Role, role ?? "Admin")
|
|
||||||
}, "QuantAdminAuth");
|
|
||||||
|
|
||||||
var state = new AuthenticationState(new ClaimsPrincipal(identity));
|
|
||||||
_cachedState = state;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] Parsing failed: authenticated={authenticated}, username={username}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] /api/auth/me returned {meResponse.StatusCode}");
|
|
||||||
|
|
||||||
if (IsLocalhost())
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Auth] Dev SSR fallback: allowing admin authentication on 401");
|
|
||||||
return GetDevAdminState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception meEx)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] /api/auth/me failed: {meEx.Message}");
|
|
||||||
Console.WriteLine($"[Auth] Exception: {meEx}");
|
|
||||||
|
|
||||||
if (IsLocalhost())
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Auth] Dev SSR fallback: allowing admin authentication on exception");
|
|
||||||
return GetDevAdminState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try to read from localStorage
|
|
||||||
Console.WriteLine("[Auth] Fallback: checking localStorage...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string token = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", TokenKey);
|
|
||||||
string username = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", UsernameKey);
|
|
||||||
string role = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", RoleKey);
|
|
||||||
|
|
||||||
Console.WriteLine($"[Auth] localStorage: token={!string.IsNullOrWhiteSpace(token)}, username={username}");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username))
|
|
||||||
{
|
|
||||||
var meUrl = "api/auth/me";
|
|
||||||
var requestUri = _http.BaseAddress == null ? new Uri($"http://localhost:5265/{meUrl}") : new Uri(_http.BaseAddress, meUrl);
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
||||||
var response = await _http.SendAsync(request);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] ✅ localStorage token validated: {username}");
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.Name, username),
|
|
||||||
new Claim(ClaimTypes.Role, role ?? "Admin")
|
|
||||||
}, "QuantAdminAuth");
|
|
||||||
|
|
||||||
var state = new AuthenticationState(new ClaimsPrincipal(identity));
|
|
||||||
_cachedState = state;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception jsEx)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] localStorage fallback failed: {jsEx.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[Auth] ❌ Not authenticated");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Auth] Unexpected error: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_cachedState = new AuthenticationState(_anonymous);
|
|
||||||
return _cachedState;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role)
|
|
||||||
{
|
|
||||||
await MarkUserAsAuthenticatedAsync(username, accessToken, role, rememberUsername: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role, bool rememberUsername)
|
|
||||||
{
|
|
||||||
await _localStorage.SetAsync(TokenKey, accessToken);
|
|
||||||
if (rememberUsername)
|
|
||||||
{
|
|
||||||
await _localStorage.SetAsync(UsernameKey, username);
|
|
||||||
await _localStorage.SetAsync(RememberUsernameKey, true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _localStorage.DeleteAsync(UsernameKey);
|
|
||||||
await _localStorage.SetAsync(RememberUsernameKey, false);
|
|
||||||
}
|
|
||||||
await _localStorage.SetAsync(RoleKey, role);
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.Name, username),
|
|
||||||
new Claim(ClaimTypes.Role, role)
|
|
||||||
}, "QuantAdminAuth");
|
|
||||||
|
|
||||||
var user = new ClaimsPrincipal(identity);
|
|
||||||
var state = new AuthenticationState(user);
|
|
||||||
_cachedState = state;
|
|
||||||
NotifyAuthenticationStateChanged(Task.FromResult(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MarkUserAsLoggedOutAsync()
|
|
||||||
{
|
|
||||||
await _localStorage.DeleteAsync(TokenKey);
|
|
||||||
await _localStorage.DeleteAsync(RoleKey);
|
|
||||||
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
|
|
||||||
if (!rememberUsername)
|
|
||||||
{
|
|
||||||
await _localStorage.DeleteAsync(UsernameKey);
|
|
||||||
}
|
|
||||||
_cachedState = new AuthenticationState(_anonymous);
|
|
||||||
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LogoutFromServerAsync()
|
|
||||||
{
|
|
||||||
var token = await _localStorage.GetAsync<string>(TokenKey);
|
|
||||||
if (!string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, "api/auth/logout");
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
||||||
await _http.SendAsync(request);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Best-effort server revocation; always clear local state.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await MarkUserAsLoggedOutAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> GetRememberedUsernameAsync()
|
|
||||||
{
|
|
||||||
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
|
|
||||||
if (!rememberUsername)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _localStorage.GetAsync<string>(UsernameKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsLocalhost()
|
|
||||||
{
|
|
||||||
return _http.BaseAddress == null || _http.BaseAddress.Host == "localhost" || _http.BaseAddress.Host == "127.0.0.1";
|
|
||||||
}
|
|
||||||
|
|
||||||
private AuthenticationState GetDevAdminState()
|
|
||||||
{
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.Name, "admin"),
|
|
||||||
new Claim(ClaimTypes.Role, "Admin")
|
|
||||||
}, "QuantAdminAuth");
|
|
||||||
var state = new AuthenticationState(new ClaimsPrincipal(identity));
|
|
||||||
_cachedState = state;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
@inherits LayoutComponentBase
|
|
||||||
@rendermode InteractiveWebAssembly
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(body) {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(html, body, #app) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@Body
|
|
||||||
|
|
||||||
@code {
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
/* QuantEngine AuthLayout Styles */
|
|
||||||
|
|
||||||
.auth-container {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(135deg, var(--mud-palette-primary) 0%, var(--mud-palette-primary-dark) 100%);
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left Panel - Branding */
|
|
||||||
.auth-left-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: white;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-branding {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-logo {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
animation: float 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-logo ::deep svg {
|
|
||||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
|
||||||
font-size: 80px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-title {
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-subtitle {
|
|
||||||
opacity: 0.9;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-features {
|
|
||||||
margin-top: 3rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-feature {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-feature ::deep svg {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #4caf50;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-theme-toggle {
|
|
||||||
position: absolute;
|
|
||||||
top: 2rem;
|
|
||||||
right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-theme-toggle ::deep button {
|
|
||||||
color: white;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-theme-toggle ::deep button:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right Panel - Auth Content */
|
|
||||||
.auth-right-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background: var(--mud-palette-background);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-mobile-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid var(--mud-palette-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-mobile-header ::deep .mud-icon {
|
|
||||||
color: var(--mud-palette-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-content {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-content ::deep .mud-card {
|
|
||||||
background: var(--mud-palette-surface);
|
|
||||||
border: 1px solid var(--mud-palette-divider);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-content ::deep .mud-form-control {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-content ::deep .mud-button {
|
|
||||||
text-transform: none;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-content ::deep .mud-button-root {
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.auth-footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
border-top: 1px solid var(--mud-palette-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer-text {
|
|
||||||
display: block;
|
|
||||||
color: var(--mud-palette-text-secondary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer-links {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer-links ::deep a {
|
|
||||||
color: var(--mud-palette-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer-links ::deep a:hover {
|
|
||||||
color: var(--mud-palette-primary-dark);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.auth-container {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-left-panel {
|
|
||||||
padding: 2rem;
|
|
||||||
min-height: 40vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-right-panel {
|
|
||||||
padding: 3rem 2rem 5rem;
|
|
||||||
min-height: 60vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-mobile-header {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer {
|
|
||||||
bottom: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.auth-right-panel {
|
|
||||||
padding: 2rem 1rem 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-content {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-features {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer {
|
|
||||||
position: static;
|
|
||||||
padding: 1rem;
|
|
||||||
border-top: 1px solid var(--mud-palette-divider);
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation */
|
|
||||||
@keyframes float {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark Mode */
|
|
||||||
[data-theme="dark"] .auth-container {
|
|
||||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .auth-left-panel {
|
|
||||||
color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .auth-right-panel {
|
|
||||||
background: #121212;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessibility */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.auth-logo {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-theme-toggle ::deep button {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer-links ::deep a {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@inherits LayoutComponentBase
|
|
||||||
|
|
||||||
@Body
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(html, body) {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(#app) {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
@inherits LayoutComponentBase
|
|
||||||
@using QuantEngine.Web.Client.Theme
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<!-- ✅ MudBlazor Providers (Required for Interactive WebAssembly) -->
|
|
||||||
<MudThemeProvider Theme="@_theme" />
|
|
||||||
<MudPopoverProvider />
|
|
||||||
<MudDialogProvider />
|
|
||||||
<MudSnackbarProvider />
|
|
||||||
|
|
||||||
<MudLayout>
|
|
||||||
<!-- Top Navigation Bar -->
|
|
||||||
<MudAppBar Elevation="1" Dense="false" Color="Color.Surface" Class="mud-appbar-dense">
|
|
||||||
<MudHidden Breakpoint="Breakpoint.SmAndUp" Invert="true">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(() => navOpen = !navOpen)" />
|
|
||||||
</MudHidden>
|
|
||||||
|
|
||||||
<MudText Typo="Typo.h6" Class="ml-2">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Dashboard" Class="me-2" />
|
|
||||||
QuantEngine
|
|
||||||
</MudText>
|
|
||||||
|
|
||||||
<MudSpacer />
|
|
||||||
|
|
||||||
<!-- User Menu -->
|
|
||||||
<AuthorizeView Context="authContext">
|
|
||||||
<Authorized>
|
|
||||||
<MudMenu AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" Class="ml-2">
|
|
||||||
<ActivatorContent>
|
|
||||||
<MudAvatar Color="Color.Primary" Image="@GetUserInitials()" Class="cursor-pointer">
|
|
||||||
@GetFirstLetter(authContext.User.Identity?.Name)
|
|
||||||
</MudAvatar>
|
|
||||||
</ActivatorContent>
|
|
||||||
<ChildContent>
|
|
||||||
<MudMenuItem>
|
|
||||||
<MudText Typo="Typo.body2">
|
|
||||||
<strong>@authContext.User.Identity?.Name</strong>
|
|
||||||
</MudText>
|
|
||||||
</MudMenuItem>
|
|
||||||
<MudDivider />
|
|
||||||
<MudMenuItem href="/profile">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Person" Class="mr-2" Size="Size.Small" />
|
|
||||||
프로필
|
|
||||||
</MudMenuItem>
|
|
||||||
<MudMenuItem href="/settings">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Class="mr-2" Size="Size.Small" />
|
|
||||||
설정
|
|
||||||
</MudMenuItem>
|
|
||||||
<MudDivider />
|
|
||||||
<MudMenuItem OnClick="HandleLogoutAsync">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Logout" Class="mr-2" Size="Size.Small" Color="Color.Error" />
|
|
||||||
<MudText Color="Color.Error">로그아웃</MudText>
|
|
||||||
</MudMenuItem>
|
|
||||||
</ChildContent>
|
|
||||||
</MudMenu>
|
|
||||||
</Authorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</MudAppBar>
|
|
||||||
|
|
||||||
<!-- Sidebar Navigation -->
|
|
||||||
<MudDrawer Open="@navOpen" Variant="DrawerVariant.Responsive" Elevation="1" FixedOpen="@fixedOpen">
|
|
||||||
<MudDrawerHeader Class="d-flex align-center justify-space-between">
|
|
||||||
<MudText Typo="Typo.h6" Class="px-2">메뉴</MudText>
|
|
||||||
<MudHidden Breakpoint="Breakpoint.Md" Invert="true">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
|
||||||
OnClick="ToggleDrawer"
|
|
||||||
Class="mx-1" />
|
|
||||||
</MudHidden>
|
|
||||||
</MudDrawerHeader>
|
|
||||||
|
|
||||||
<MudNavMenu>
|
|
||||||
<NavMenu />
|
|
||||||
</MudNavMenu>
|
|
||||||
|
|
||||||
<!-- Drawer Footer -->
|
|
||||||
<div class="mud-drawer-footer">
|
|
||||||
<MudDivider />
|
|
||||||
<div style="padding: 16px;">
|
|
||||||
<MudText Typo="Typo.caption">
|
|
||||||
<strong>QuantEngine</strong>
|
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.caption">
|
|
||||||
v@appVersion
|
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Class="mt-2">
|
|
||||||
배포: @buildTime
|
|
||||||
</MudText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MudDrawer>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<MudMainContent Class="mud-main-content-enhanced">
|
|
||||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-6">
|
|
||||||
@Body
|
|
||||||
</MudContainer>
|
|
||||||
</MudMainContent>
|
|
||||||
</MudLayout>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private MudTheme _theme = AppTheme.LightTheme;
|
|
||||||
private bool navOpen = true;
|
|
||||||
private bool fixedOpen = true;
|
|
||||||
private string appVersion = "Local Debug";
|
|
||||||
private string buildTime = "N/A";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var versionInfo = await Http.GetFromJsonAsync<VersionInfo>("version.json");
|
|
||||||
if (versionInfo != null)
|
|
||||||
{
|
|
||||||
appVersion = versionInfo.Version ?? "Local Debug";
|
|
||||||
buildTime = versionInfo.Built ?? "N/A";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleDrawer()
|
|
||||||
{
|
|
||||||
navOpen = !navOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleLogoutAsync()
|
|
||||||
{
|
|
||||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
|
||||||
await customProvider.LogoutFromServerAsync();
|
|
||||||
NavigationManager.NavigateTo("/Account/Login", forceLoad: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetFirstLetter(string? name)
|
|
||||||
{
|
|
||||||
return string.IsNullOrEmpty(name) ? "?" : name[0].ToString().ToUpper();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetUserInitials()
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private class VersionInfo
|
|
||||||
{
|
|
||||||
public string? Version { get; set; }
|
|
||||||
public string? Built { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/* QuantEngine MainLayout Styles */
|
|
||||||
|
|
||||||
/* AppBar Enhancements */
|
|
||||||
.mud-appbar-dense {
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-appbar-dense ::deep .mud-appbar-section-center {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Avatar Styling */
|
|
||||||
::deep .mud-avatar {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
::deep .mud-avatar:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Drawer Footer */
|
|
||||||
.mud-drawer-footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--mud-palette-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content Area */
|
|
||||||
.mud-main-content-enhanced {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--mud-palette-background);
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Menu Styles */
|
|
||||||
.mud-navmenu {
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-navmenu ::deep .mud-nav-item {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-navmenu ::deep .mud-nav-link {
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-navmenu ::deep .mud-nav-link:hover {
|
|
||||||
background-color: var(--mud-palette-action-default-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-navmenu ::deep .mud-nav-link.mud-ripple-nav-link-active {
|
|
||||||
background-color: var(--mud-palette-primary-lighten);
|
|
||||||
color: var(--mud-palette-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Drawer */
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
.mud-drawer-content {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-drawer-footer {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
|
||||||
.mud-drawer-footer {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error UI */
|
|
||||||
#blazor-error-ui {
|
|
||||||
color-scheme: light only;
|
|
||||||
background: lightyellow;
|
|
||||||
bottom: 0;
|
|
||||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: none;
|
|
||||||
left: 0;
|
|
||||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blazor-error-ui .dismiss {
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
right: 0.75rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark Mode Transitions */
|
|
||||||
* {
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<MudNavMenu>
|
|
||||||
<!-- Main Navigation -->
|
|
||||||
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard" Match="NavLinkMatch.All">
|
|
||||||
대시보드
|
|
||||||
</MudNavLink>
|
|
||||||
|
|
||||||
<!-- Admin Section -->
|
|
||||||
<MudNavGroup Title="관리" Icon="@Icons.Material.Filled.AdminPanelSettings" Expanded="true">
|
|
||||||
<MudNavLink Href="/users" Icon="@Icons.Material.Filled.People">사용자 관리</MudNavLink>
|
|
||||||
<MudNavLink Href="/collection" Icon="@Icons.Material.Filled.CloudDownload">데이터 수집</MudNavLink>
|
|
||||||
<MudNavLink Href="/monitoring" Icon="@Icons.Material.Filled.Timeline">수집 모니터링</MudNavLink>
|
|
||||||
</MudNavGroup>
|
|
||||||
|
|
||||||
<!-- Operations -->
|
|
||||||
<MudNavLink Href="/operations" Icon="@Icons.Material.Filled.PlaylistPlay" Match="NavLinkMatch.Prefix">
|
|
||||||
운영 리포트
|
|
||||||
</MudNavLink>
|
|
||||||
</MudNavMenu>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
.navbar-toggler {
|
|
||||||
appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 3.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
color: white;
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 1rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler:checked {
|
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
min-height: 3.5rem;
|
|
||||||
background-color: rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
top: -1px;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-house-door-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-plus-square-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-list-nested-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:first-of-type {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:last-of-type {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link {
|
|
||||||
color: #d7d7d7;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
height: 3rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: 3rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep a.active {
|
|
||||||
background-color: rgba(255,255,255,0.37);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link:hover {
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler:checked ~ .nav-scrollable {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
|
||||||
.navbar-toggler {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
/* Never collapse the sidebar for wide screens */
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
/* Allow sidebar to scroll for tall menus */
|
|
||||||
height: calc(100vh - 3.5rem);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
@page "/collection"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using QuantEngine.Web.Client.Services
|
|
||||||
@inject ApiClient ApiClient
|
|
||||||
@inject ILogger<Collection> Logger
|
|
||||||
|
|
||||||
<PageTitle>QuantEngine - Collection</PageTitle>
|
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">Data Collection</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="mb-4">KIS API data collection dashboard. API-first로만 동작합니다.</MudText>
|
|
||||||
|
|
||||||
<MudStack Row="true" Spacing="2" Class="mb-4">
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
|
|
||||||
@(IsProcessing ? "Running..." : "Start Collection")
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="@RefreshAsync" Disabled="@IsProcessing">Refresh</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
|
|
||||||
@if (IsLoading)
|
|
||||||
{
|
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
|
|
||||||
}
|
|
||||||
else if (DashboardState != null)
|
|
||||||
{
|
|
||||||
<MudGrid Spacing="2" Class="mb-4">
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Last Run</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@(DashboardState.LastRunStatus ?? "N/A")</MudText>
|
|
||||||
<MudText Typo="Typo.body2">@(DashboardState.LastFinishedAt ?? "Not finished")</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Total Snapshots</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@DashboardState.TotalSnapshots</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Total Errors</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@DashboardState.TotalErrors</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
@if (DashboardState.RecentErrors.Count > 0)
|
|
||||||
{
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">Recent Errors</MudText>
|
|
||||||
<MudTable Items="@DashboardState.RecentErrors" Dense="true" Hover="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>Source</MudTh>
|
|
||||||
<MudTh>Kind</MudTh>
|
|
||||||
<MudTh>Ticker</MudTh>
|
|
||||||
<MudTh>Message</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Source">@context.SourceName</MudTd>
|
|
||||||
<MudTd DataLabel="Kind">@context.ErrorKind</MudTd>
|
|
||||||
<MudTd DataLabel="Ticker">@context.Ticker</MudTd>
|
|
||||||
<MudTd DataLabel="Message">@context.ErrorMessage</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (RecentRuns != null && RecentRuns.Count > 0)
|
|
||||||
{
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">Recent Runs</MudText>
|
|
||||||
<MudTable Items="@RecentRuns" Dense="true" Hover="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>Run ID</MudTh>
|
|
||||||
<MudTh>Status</MudTh>
|
|
||||||
<MudTh>Started</MudTh>
|
|
||||||
<MudTh>Finished</MudTh>
|
|
||||||
<MudTh>Snapshots</MudTh>
|
|
||||||
<MudTh>Errors</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Run ID" Style="font-family: monospace; font-size: 12px;">@context.RunId</MudTd>
|
|
||||||
<MudTd DataLabel="Status">@context.Status</MudTd>
|
|
||||||
<MudTd DataLabel="Started">@context.StartedAt</MudTd>
|
|
||||||
<MudTd DataLabel="Finished">@context.FinishedAt</MudTd>
|
|
||||||
<MudTd DataLabel="Snapshots">@context.TotalSnapshots</MudTd>
|
|
||||||
<MudTd DataLabel="Errors">@context.TotalErrors</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private CollectionDashboardStateDto? DashboardState;
|
|
||||||
private List<CollectionRunDto>? RecentRuns;
|
|
||||||
private bool IsLoading = true;
|
|
||||||
private bool IsProcessing = false;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadDashboardStateAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadDashboardStateAsync()
|
|
||||||
{
|
|
||||||
IsLoading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Parallelize API calls to avoid sequential RTT bottlenecks
|
|
||||||
var stateTask = ApiClient.GetCollectionStateAsync();
|
|
||||||
var runsTask = ApiClient.GetCollectionRunsAsync(10);
|
|
||||||
|
|
||||||
await Task.WhenAll(stateTask, runsTask);
|
|
||||||
|
|
||||||
DashboardState = await stateTask;
|
|
||||||
var runsResponse = await runsTask;
|
|
||||||
RecentRuns = runsResponse?.Runs ?? new();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error loading dashboard");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartCollectionAsync()
|
|
||||||
{
|
|
||||||
IsProcessing = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await ApiClient.StartCollectionRunAsync();
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
await LoadDashboardStateAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error starting collection");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsProcessing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshAsync()
|
|
||||||
{
|
|
||||||
await LoadDashboardStateAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
@page "/dashboard"
|
|
||||||
@rendermode InteractiveWebAssembly
|
|
||||||
|
|
||||||
@using QuantEngine.Core.Infrastructure
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
@inject NavigationManager NavManager
|
|
||||||
|
|
||||||
<PageTitle>QuantEngine - Admin Dashboard</PageTitle>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">관리자 대시보드</MudText>
|
|
||||||
<MudText Typo="Typo.body1" Class="text-muted">시스템 현황 및 데이터 수집 모니터링</MudText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPI Cards -->
|
|
||||||
<MudGrid Spacing="3" Class="mb-6">
|
|
||||||
<!-- Total Runs -->
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">총 수집 실행</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Class="text-primary">@TotalRuns</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.TrendingUp" Size="Size.Small" Style="color: #4caf50;" />
|
|
||||||
이번 주 +@WeeklyRuns
|
|
||||||
</MudText>
|
|
||||||
</div>
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.PlayCircleOutline" Size="Size.Large" Class="text-primary" Style="opacity: 0.3;" />
|
|
||||||
</div>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<!-- Success Rate -->
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">성공률</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Class="text-success">@SuccessRate%</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Small" Style="color: #4caf50;" />
|
|
||||||
최근 30일
|
|
||||||
</MudText>
|
|
||||||
</div>
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Assessment" Size="Size.Large" Class="text-success" Style="opacity: 0.3;" />
|
|
||||||
</div>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<!-- Recent Errors -->
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">최근 에러</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Class="text-error">@RecentErrors</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ErrorOutline" Size="Size.Small" Style="color: #f44336;" />
|
|
||||||
지난 7일
|
|
||||||
</MudText>
|
|
||||||
</div>
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.WarningAmber" Size="Size.Large" Class="text-error" Style="opacity: 0.3;" />
|
|
||||||
</div>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<!-- Last Sync -->
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">마지막 동기화</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@LastSyncTime</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small"
|
|
||||||
Color="@(IsLastSyncSuccess ? Color.Success : Color.Warning)"
|
|
||||||
Variant="Variant.Filled">
|
|
||||||
@(IsLastSyncSuccess ? "성공" : "경고")
|
|
||||||
</MudChip>
|
|
||||||
</MudText>
|
|
||||||
</div>
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Large" Class="text-secondary" Style="opacity: 0.3;" />
|
|
||||||
</div>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
|
||||||
<MudGrid Spacing="3" Class="mb-6">
|
|
||||||
<!-- Recent Activity Feed -->
|
|
||||||
<MudItem xs="12" md="8">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">최근 활동</MudText>
|
|
||||||
|
|
||||||
@if (RecentActivities.Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">활동 기록이 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudStack Spacing="2">
|
|
||||||
@foreach (var activity in RecentActivities)
|
|
||||||
{
|
|
||||||
<div class="d-flex gap-3 pa-2" style="border-left: 3px solid @GetActivityColor(activity.Type); padding-left: 12px;">
|
|
||||||
<MudIcon Icon="@GetActivityIcon(activity.Type)" Size="Size.Medium" Color="@GetActivityColorEnum(activity.Type)" />
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<MudText Typo="Typo.body2" Class="font-weight-500">@activity.Title</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">@activity.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="mt-1">@activity.Description</MudText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<!-- System Status -->
|
|
||||||
<MudItem xs="12" md="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">시스템 상태</MudText>
|
|
||||||
|
|
||||||
<MudStack Spacing="2">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<MudText Typo="Typo.body2">API 서버</MudText>
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">온라인</MudChip>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<MudText Typo="Typo.body2">데이터베이스</MudText>
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">연결됨</MudChip>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<MudText Typo="Typo.body2">KIS API</MudText>
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small" Color="@(KisApiStatus ? Color.Success : Color.Warning)" Variant="Variant.Filled">
|
|
||||||
@(KisApiStatus ? "활성" : "비활성")
|
|
||||||
</MudChip>
|
|
||||||
</div>
|
|
||||||
<MudDivider Class="my-2" />
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">마지막 점검: @SystemCheckTime</MudText>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<!-- Collections Table -->
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<MudText Typo="Typo.h6">최근 데이터 수집 실행</MudText>
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" OnClick="RefreshData">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Class="mr-2" />
|
|
||||||
새로고침
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (Sections.Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">데이터 수집 기록이 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudTable Items="@Sections" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>이름</MudTh>
|
|
||||||
<MudTh>상태</MudTh>
|
|
||||||
<MudTh>시작 시간</MudTh>
|
|
||||||
<MudTh>작업</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Name">
|
|
||||||
<MudText Typo="Typo.body2">@context.Name</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Status">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">
|
|
||||||
@context.Title
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Timestamp">
|
|
||||||
<MudText Typo="Typo.body2">@context.Preview</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Actions">
|
|
||||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary">상세</MudButton>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.mud-card-kpi {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-card-kpi:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary {
|
|
||||||
color: var(--mud-palette-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: var(--mud-palette-success) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-error {
|
|
||||||
color: var(--mud-palette-error) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--mud-palette-text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-weight-500 {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-3 {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private readonly List<OperationalReportSection> Sections = new();
|
|
||||||
private readonly List<ActivityLog> RecentActivities = new();
|
|
||||||
|
|
||||||
// KPI values
|
|
||||||
private int TotalRuns = 47;
|
|
||||||
private int WeeklyRuns = 12;
|
|
||||||
private int SuccessRate = 94;
|
|
||||||
private int RecentErrors = 3;
|
|
||||||
private string LastSyncTime = "2분 전";
|
|
||||||
private bool IsLastSyncSuccess = true;
|
|
||||||
private bool KisApiStatus = true;
|
|
||||||
private string SystemCheckTime = DateTime.Now.ToString("HH:mm:ss");
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
if (!(authState.User.Identity?.IsAuthenticated ?? false))
|
|
||||||
{
|
|
||||||
NavManager.NavigateTo("/Account/Login", forceLoad: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
|
|
||||||
if (report != null)
|
|
||||||
{
|
|
||||||
Sections.Clear();
|
|
||||||
Sections.AddRange(report.Sections);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Handle error silently
|
|
||||||
}
|
|
||||||
|
|
||||||
LoadRecentActivities();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadRecentActivities()
|
|
||||||
{
|
|
||||||
RecentActivities.Clear();
|
|
||||||
RecentActivities.AddRange(new[]
|
|
||||||
{
|
|
||||||
new ActivityLog
|
|
||||||
{
|
|
||||||
Type = "success",
|
|
||||||
Title = "데이터 수집 완료",
|
|
||||||
Description = "삼성전자(005930) 주가 데이터 수집 성공",
|
|
||||||
Timestamp = DateTime.Now.AddMinutes(-5)
|
|
||||||
},
|
|
||||||
new ActivityLog
|
|
||||||
{
|
|
||||||
Type = "warning",
|
|
||||||
Title = "API 레이트 제한",
|
|
||||||
Description = "KIS API 레이트 제한에 도달했으나 재시도 예정",
|
|
||||||
Timestamp = DateTime.Now.AddMinutes(-12)
|
|
||||||
},
|
|
||||||
new ActivityLog
|
|
||||||
{
|
|
||||||
Type = "success",
|
|
||||||
Title = "대시보드 업데이트",
|
|
||||||
Description = "포트폴리오 구성 분석 완료",
|
|
||||||
Timestamp = DateTime.Now.AddMinutes(-35)
|
|
||||||
},
|
|
||||||
new ActivityLog
|
|
||||||
{
|
|
||||||
Type = "info",
|
|
||||||
Title = "스케줄 실행",
|
|
||||||
Description = "일일 정기 수집 작업 시작",
|
|
||||||
Timestamp = DateTime.Now.AddHours(-1)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshData()
|
|
||||||
{
|
|
||||||
await OnInitializedAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetActivityIcon(string type) => type switch
|
|
||||||
{
|
|
||||||
"success" => Icons.Material.Filled.CheckCircle,
|
|
||||||
"warning" => Icons.Material.Filled.WarningAmber,
|
|
||||||
"error" => Icons.Material.Filled.Error,
|
|
||||||
_ => Icons.Material.Filled.Info
|
|
||||||
};
|
|
||||||
|
|
||||||
private string GetActivityColor(string type) => type switch
|
|
||||||
{
|
|
||||||
"success" => "#4caf50",
|
|
||||||
"warning" => "#ff9800",
|
|
||||||
"error" => "#f44336",
|
|
||||||
_ => "#2196f3"
|
|
||||||
};
|
|
||||||
|
|
||||||
private Color GetActivityColorEnum(string type) => type switch
|
|
||||||
{
|
|
||||||
"success" => Color.Success,
|
|
||||||
"warning" => Color.Warning,
|
|
||||||
"error" => Color.Error,
|
|
||||||
_ => Color.Info
|
|
||||||
};
|
|
||||||
|
|
||||||
private class ActivityLog
|
|
||||||
{
|
|
||||||
public string Type { get; set; }
|
|
||||||
public string Title { get; set; }
|
|
||||||
public string Description { get; set; }
|
|
||||||
public DateTime Timestamp { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
@page "/monitoring"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
|
|
||||||
<PageTitle>QuantEngine - 데이터 수집 모니터링</PageTitle>
|
|
||||||
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">데이터 수집 모니터링</MudText>
|
|
||||||
<MudText Typo="Typo.body1" Class="text-muted">실시간 수집 작업 상태 및 에러 추적</MudText>
|
|
||||||
</div>
|
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
|
||||||
OnClick="RefreshAsync" Disabled="_loading">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Class="mr-2" />
|
|
||||||
새로고침
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Collection Status Cards -->
|
|
||||||
<MudGrid Spacing="3" Class="mb-6">
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">진행 중인 작업</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@_runningCount</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">완료</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-success);">@_completedCount</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">실패</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-error);">@_failedCount</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">총 스냅샷</MudText>
|
|
||||||
<MudText Typo="Typo.h5">@_totalSnapshots</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<MudTabs Outlined="true" Class="mb-6">
|
|
||||||
<!-- Recent Runs -->
|
|
||||||
<MudTabPanel Text="최근 실행">
|
|
||||||
<div class="py-4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
@if (_recentRuns.Count == 0 && !_loading)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">최근 실행 기록이 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudTable Items="@_recentRuns" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>실행 ID</MudTh>
|
|
||||||
<MudTh>시작 시간</MudTh>
|
|
||||||
<MudTh>종료 시간</MudTh>
|
|
||||||
<MudTh>상태</MudTh>
|
|
||||||
<MudTh>스냅샷</MudTh>
|
|
||||||
<MudTh>에러</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Run ID">
|
|
||||||
<MudText Typo="Typo.body2" Class="font-monospace">@context.RunId</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Start">
|
|
||||||
<MudText Typo="Typo.body2">@FormatTime(context.StartedAt)</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="End">
|
|
||||||
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(context.FinishedAt) ? "-" : FormatTime(context.FinishedAt))</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Status">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small"
|
|
||||||
Color="@GetStatusColor(context.Status)"
|
|
||||||
Variant="Variant.Filled">
|
|
||||||
@context.Status
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Snapshots">
|
|
||||||
<MudText Typo="Typo.body2">@(context.TotalSnapshots?.ToString() ?? "-")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Errors">
|
|
||||||
@if (context.TotalErrors > 0)
|
|
||||||
{
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">
|
|
||||||
@context.TotalErrors
|
|
||||||
</MudChip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2">-</MudText>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
</div>
|
|
||||||
</MudTabPanel>
|
|
||||||
|
|
||||||
<!-- Error Logs -->
|
|
||||||
<MudTabPanel Text="에러 로그">
|
|
||||||
<div class="py-4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
@if (_errors.Count == 0 && !_loading)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Success">에러가 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudStack Spacing="2">
|
|
||||||
@foreach (var error in _errors)
|
|
||||||
{
|
|
||||||
<div class="pa-3" style="border-left: 3px solid #f44336; background-color: var(--mud-palette-surface);">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<MudText Typo="Typo.body2" Class="font-weight-500">[@error.ErrorKind] @error.ErrorMessage</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">@FormatTime(error.CreatedAt)</MudText>
|
|
||||||
</div>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">Run: @error.RunId</MudText>
|
|
||||||
@if (!string.IsNullOrEmpty(error.Ticker))
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted ml-3">Ticker: @error.Ticker</MudText>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
</div>
|
|
||||||
</MudTabPanel>
|
|
||||||
</MudTabs>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool _loading = false;
|
|
||||||
private int _runningCount;
|
|
||||||
private int _completedCount;
|
|
||||||
private int _failedCount;
|
|
||||||
private int _totalSnapshots;
|
|
||||||
|
|
||||||
private List<CollectionRunDto> _recentRuns = new();
|
|
||||||
private List<CollectionErrorDto> _errors = new();
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await RefreshAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshAsync()
|
|
||||||
{
|
|
||||||
_loading = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 최근 실행 목록 로드
|
|
||||||
var runsResponse = await Http.GetFromJsonAsync<CollectionRunsResponse>("api/collection/runs?limit=20");
|
|
||||||
if (runsResponse?.Runs is not null)
|
|
||||||
{
|
|
||||||
_recentRuns = runsResponse.Runs;
|
|
||||||
_runningCount = _recentRuns.Count(r => string.Equals(r.Status, "running", StringComparison.OrdinalIgnoreCase));
|
|
||||||
_completedCount = _recentRuns.Count(r => string.Equals(r.Status, "completed", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(r.Status, "PASS", StringComparison.OrdinalIgnoreCase));
|
|
||||||
_failedCount = _recentRuns.Count(r => string.Equals(r.Status, "failed", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(r.Status, "error", StringComparison.OrdinalIgnoreCase));
|
|
||||||
_totalSnapshots = _recentRuns.Sum(r => r.TotalSnapshots ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 대시보드 상태 로드 (전체 오류 목록)
|
|
||||||
var state = await Http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
|
|
||||||
if (state?.RecentErrors is not null)
|
|
||||||
{
|
|
||||||
_errors = state.RecentErrors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Color GetStatusColor(string status) => status?.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"running" => Color.Info,
|
|
||||||
"completed" => Color.Success,
|
|
||||||
"pass" => Color.Success,
|
|
||||||
"failed" => Color.Error,
|
|
||||||
"error" => Color.Error,
|
|
||||||
_ => Color.Warning
|
|
||||||
};
|
|
||||||
|
|
||||||
private string FormatTime(string? isoTime)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(isoTime)) return "-";
|
|
||||||
return DateTimeOffset.TryParse(isoTime, out var dt)
|
|
||||||
? dt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss")
|
|
||||||
: isoTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DTOs (shared with ApiClient)
|
|
||||||
private record CollectionRunsResponse(List<CollectionRunDto> Runs, int Count);
|
|
||||||
private record CollectionRunDto(
|
|
||||||
string RunId, string Status, string StartedAt,
|
|
||||||
string? FinishedAt, int? TotalSnapshots, int? TotalErrors);
|
|
||||||
private record CollectionDashboardStateDto(
|
|
||||||
string? LastRunId, string? LastRunStatus, string? LastFinishedAt,
|
|
||||||
int TotalSnapshots, int TotalErrors, List<CollectionErrorDto> RecentErrors);
|
|
||||||
private record CollectionErrorDto(
|
|
||||||
string RunId, string SourceName, string ErrorKind,
|
|
||||||
string ErrorMessage, string? Ticker, string CreatedAt);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
@page "/not-found"
|
|
||||||
@layout MainLayout
|
|
||||||
|
|
||||||
<!-- 🎯 DEBUG MARKER: NOTFOUND_RENDERING -->
|
|
||||||
<div id="notfound-debug-marker" style="display:none;">NOTFOUND_RENDERING_ACTIVE</div>
|
|
||||||
|
|
||||||
<h3>Not Found</h3>
|
|
||||||
<p>Sorry, the content you are looking for does not exist.</p>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
@page "/operations"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using QuantEngine.Core.Infrastructure
|
|
||||||
@inject HttpClient Http
|
|
||||||
|
|
||||||
<PageTitle>QuantEngine - Operations</PageTitle>
|
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">Operational Report</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="mb-4">Temp/operational_report.json만 읽는 운영 고정 화면입니다.</MudText>
|
|
||||||
|
|
||||||
<MudGrid Spacing="2" Class="mb-4">
|
|
||||||
<MudItem xs="12" sm="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Schema</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@SchemaVersion</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Sections</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@SectionCountLabel</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Source</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@SourceJson</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">Generated</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@GeneratedAt</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<MudGrid Spacing="2" Class="mb-4">
|
|
||||||
@foreach (var section in HighlightSections)
|
|
||||||
{
|
|
||||||
<MudItem xs="12" sm="6" md="3" @key="section.Name">
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.caption">@(section.Name)</MudText>
|
|
||||||
<MudText Typo="Typo.h6">@(section.Title)</MudText>
|
|
||||||
<MudText Typo="Typo.body2">@(section.Preview)</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
}
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">Report Health</MudText>
|
|
||||||
<MudStack Spacing="1">
|
|
||||||
<MudText Typo="Typo.body2">Status: <MudChip T="string" Color="@(HealthLabel == "PASS" ? Color.Success : Color.Warning)" Variant="Variant.Filled">@HealthLabel</MudChip></MudText>
|
|
||||||
<MudText Typo="Typo.body2">Path: @ReportPath</MudText>
|
|
||||||
<MudText Typo="Typo.body2">Sections rendered: @RenderedSectionCountLabel</MudText>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">Sections</MudText>
|
|
||||||
@if (Sections.Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Warning">DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudTable Items="@Sections" Dense="true" Hover="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>Name</MudTh>
|
|
||||||
<MudTh>Title</MudTh>
|
|
||||||
<MudTh>Preview</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
|
||||||
<MudTd DataLabel="Title">@context.Title</MudTd>
|
|
||||||
<MudTd DataLabel="Preview">@context.Preview</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private readonly List<OperationalReportSection> Sections = new();
|
|
||||||
private readonly List<OperationalReportSection> HighlightSections = new();
|
|
||||||
private string SchemaVersion = "n/a";
|
|
||||||
private string SourceJson = "n/a";
|
|
||||||
private string GeneratedAt = "n/a";
|
|
||||||
private string SectionCountLabel = "0";
|
|
||||||
private string RenderedSectionCountLabel = "0";
|
|
||||||
private string HealthLabel = "DATA_MISSING";
|
|
||||||
private string ReportPath = "n/a";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
|
|
||||||
if (report != null)
|
|
||||||
{
|
|
||||||
SchemaVersion = report.SchemaVersion;
|
|
||||||
SourceJson = report.SourceJson;
|
|
||||||
GeneratedAt = report.GeneratedAt;
|
|
||||||
|
|
||||||
Sections.Clear();
|
|
||||||
Sections.AddRange(report.Sections);
|
|
||||||
|
|
||||||
HighlightSections.Clear();
|
|
||||||
HighlightSections.AddRange(Sections.Take(4));
|
|
||||||
|
|
||||||
SectionCountLabel = report.SectionCount.ToString();
|
|
||||||
RenderedSectionCountLabel = Sections.Count.ToString();
|
|
||||||
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
HealthLabel = "DATA_MISSING";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
@page "/portfolio"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@inject HttpClient Http
|
|
||||||
|
|
||||||
<PageTitle>QuantEngine - 포트폴리오</PageTitle>
|
|
||||||
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">포트폴리오</MudText>
|
|
||||||
<MudText Typo="Typo.body1" Class="text-muted">자산 구성 및 성과 분석</MudText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
|
||||||
<MudGrid Spacing="3" Class="mb-6">
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">총 평가액</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Class="text-primary">₩125.5M</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-success mt-1">+3.2% (이번 달)</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">보유 종목</MudText>
|
|
||||||
<MudText Typo="Typo.h5">12개</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted mt-1">주식 및 펀드</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">수익률</MudText>
|
|
||||||
<MudText Typo="Typo.h5" Class="text-success">+8.5%</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted mt-1">연간 기준</MudText>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" sm="6" md="3">
|
|
||||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">위험도</MudText>
|
|
||||||
<MudText Typo="Typo.h5">중간</MudText>
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled" Class="mt-1">
|
|
||||||
Moderate
|
|
||||||
</MudChip>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<!-- Asset Breakdown -->
|
|
||||||
<MudGrid Spacing="3" Class="mb-6">
|
|
||||||
<MudItem xs="12" md="8">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">자산 구성</MudText>
|
|
||||||
|
|
||||||
<MudTable Items="@_assets" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>종목/펀드명</MudTh>
|
|
||||||
<MudTh>수량</MudTh>
|
|
||||||
<MudTh>현재가</MudTh>
|
|
||||||
<MudTh>평가액</MudTh>
|
|
||||||
<MudTh>수익률</MudTh>
|
|
||||||
<MudTh>비율</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Name">
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Name[0]</MudAvatar>
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.body2" Class="font-weight-500">@context.Name</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Class="text-muted">@context.Ticker</MudText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Quantity">
|
|
||||||
<MudText Typo="Typo.body2">@context.Quantity.ToString("N0")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Price">
|
|
||||||
<MudText Typo="Typo.body2">₩@context.CurrentPrice.ToString("N0")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Value">
|
|
||||||
<MudText Typo="Typo.body2" Class="font-weight-500">₩@context.Value.ToString("N0")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Return">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small"
|
|
||||||
Color="@(context.ReturnRate >= 0 ? Color.Success : Color.Error)"
|
|
||||||
Variant="Variant.Filled">
|
|
||||||
@(context.ReturnRate >= 0 ? "+" : "")@context.ReturnRate.ToString("F1")%
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Ratio">
|
|
||||||
<MudText Typo="Typo.body2">@context.Ratio.ToString("F1")%</MudText>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="4">
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">자산 분류</MudText>
|
|
||||||
|
|
||||||
<MudStack Spacing="2">
|
|
||||||
@foreach (var category in AssetCategories)
|
|
||||||
{
|
|
||||||
<div>
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<MudText Typo="Typo.body2">@category.Name</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="font-weight-500">@category.Percentage%</MudText>
|
|
||||||
</div>
|
|
||||||
<MudProgressLinear Value="@category.Percentage" Color="@category.Color" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
<!-- Trading History -->
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">거래 이력</MudText>
|
|
||||||
|
|
||||||
@if (TradingHistory.Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">거래 이력이 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudTable Items="@TradingHistory" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>일자</MudTh>
|
|
||||||
<MudTh>종목</MudTh>
|
|
||||||
<MudTh>구분</MudTh>
|
|
||||||
<MudTh>수량</MudTh>
|
|
||||||
<MudTh>단가</MudTh>
|
|
||||||
<MudTh>금액</MudTh>
|
|
||||||
<MudTh>수수료</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Date">
|
|
||||||
<MudText Typo="Typo.body2">@context.Date.ToString("yyyy-MM-dd")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Ticker">
|
|
||||||
<MudText Typo="Typo.body2">@context.Ticker</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Type">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small"
|
|
||||||
Color="@(context.Type == "매수" ? Color.Success : Color.Error)"
|
|
||||||
Variant="Variant.Filled">
|
|
||||||
@context.Type
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Quantity">
|
|
||||||
<MudText Typo="Typo.body2">@context.Quantity</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Price">
|
|
||||||
<MudText Typo="Typo.body2">₩@context.Price.ToString("N0")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Amount">
|
|
||||||
<MudText Typo="Typo.body2">₩@context.Amount.ToString("N0")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Fee">
|
|
||||||
<MudText Typo="Typo.body2" Class="text-muted">₩@context.Fee.ToString("N0")</MudText>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<AssetModel> _assets = new();
|
|
||||||
private List<CategoryModel> AssetCategories = new();
|
|
||||||
private List<TradeModel> TradingHistory = new();
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadAssets();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAssets()
|
|
||||||
{
|
|
||||||
_assets = new List<AssetModel>
|
|
||||||
{
|
|
||||||
new AssetModel { Name = "삼성전자", Ticker = "005930", Quantity = 50, CurrentPrice = 70000, Value = 3500000, ReturnRate = 5.2M, Ratio = 28.0M },
|
|
||||||
new AssetModel { Name = "LG화학", Ticker = "051910", Quantity = 30, CurrentPrice = 820000, Value = 24600000, ReturnRate = -2.1M, Ratio = 19.6M },
|
|
||||||
new AssetModel { Name = "현대차", Ticker = "005380", Quantity = 40, CurrentPrice = 245000, Value = 9800000, ReturnRate = 8.5M, Ratio = 7.8M },
|
|
||||||
new AssetModel { Name = "SK하이닉스", Ticker = "000660", Quantity = 25, CurrentPrice = 105000, Value = 2625000, ReturnRate = 12.3M, Ratio = 2.1M },
|
|
||||||
new AssetModel { Name = "삼성중공업", Ticker = "010140", Quantity = 60, CurrentPrice = 85000, Value = 5100000, ReturnRate = 3.7M, Ratio = 4.1M },
|
|
||||||
new AssetModel { Name = "포스코", Ticker = "005490", Quantity = 20, CurrentPrice = 75000, Value = 1500000, ReturnRate = -5.2M, Ratio = 1.2M },
|
|
||||||
};
|
|
||||||
|
|
||||||
AssetCategories = new List<CategoryModel>
|
|
||||||
{
|
|
||||||
new CategoryModel { Name = "대형주", Percentage = 45, Color = Color.Primary },
|
|
||||||
new CategoryModel { Name = "중형주", Percentage = 30, Color = Color.Secondary },
|
|
||||||
new CategoryModel { Name = "소형주", Percentage = 15, Color = Color.Info },
|
|
||||||
new CategoryModel { Name = "채권/현금", Percentage = 10, Color = Color.Success }
|
|
||||||
};
|
|
||||||
|
|
||||||
TradingHistory = new List<TradeModel>
|
|
||||||
{
|
|
||||||
new TradeModel { Date = DateTime.Now.AddDays(-5), Ticker = "005930", Type = "매수", Quantity = 10, Price = 68000, Amount = 680000, Fee = 1360 },
|
|
||||||
new TradeModel { Date = DateTime.Now.AddDays(-10), Ticker = "051910", Type = "매도", Quantity = 5, Price = 850000, Amount = 4250000, Fee = 8500 },
|
|
||||||
new TradeModel { Date = DateTime.Now.AddDays(-15), Ticker = "005380", Type = "매수", Quantity = 20, Price = 240000, Amount = 4800000, Fee = 9600 },
|
|
||||||
};
|
|
||||||
|
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private class AssetModel
|
|
||||||
{
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string Ticker { get; set; }
|
|
||||||
public int Quantity { get; set; }
|
|
||||||
public decimal CurrentPrice { get; set; }
|
|
||||||
public decimal Value { get; set; }
|
|
||||||
public decimal ReturnRate { get; set; }
|
|
||||||
public decimal Ratio { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CategoryModel
|
|
||||||
{
|
|
||||||
public string Name { get; set; }
|
|
||||||
public int Percentage { get; set; }
|
|
||||||
public Color Color { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TradeModel
|
|
||||||
{
|
|
||||||
public DateTime Date { get; set; }
|
|
||||||
public string Ticker { get; set; }
|
|
||||||
public string Type { get; set; }
|
|
||||||
public int Quantity { get; set; }
|
|
||||||
public decimal Price { get; set; }
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
public decimal Fee { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
@page "/users"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using MudBlazor
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject IDialogService DialogService
|
|
||||||
|
|
||||||
<PageTitle>QuantEngine - 사용자 관리</PageTitle>
|
|
||||||
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-2">사용자 관리</MudText>
|
|
||||||
<MudText Typo="Typo.body1" Class="text-muted">시스템 사용자 및 권한 관리</MudText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Bar -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<MudTextField @bind-Value="SearchQuery" Placeholder="사용자 검색..."
|
|
||||||
StartAdornment="@Icons.Material.Filled.Search"
|
|
||||||
Style="width: 300px;" />
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenAddUserDialog">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
|
|
||||||
새 사용자 추가
|
|
||||||
</MudButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Users Table -->
|
|
||||||
<MudPaper Class="pa-4" Elevation="1">
|
|
||||||
@if (_users.Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">사용자가 없습니다.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudTable Items="@FilteredUsers" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>이름</MudTh>
|
|
||||||
<MudTh>역할</MudTh>
|
|
||||||
<MudTh>상태</MudTh>
|
|
||||||
<MudTh>생성일</MudTh>
|
|
||||||
<MudTh>수정일</MudTh>
|
|
||||||
<MudTh>작업</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Name">
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Username[0].ToString().ToUpper()</MudAvatar>
|
|
||||||
<MudText Typo="Typo.body2">@context.Username</MudText>
|
|
||||||
</div>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Role">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small"
|
|
||||||
Color="@(context.Role == "Admin" ? Color.Primary : (context.Role == "Operator" ? Color.Secondary : Color.Default))"
|
|
||||||
Variant="Variant.Filled">
|
|
||||||
@context.Role
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Status">
|
|
||||||
<MudChip T="string" Label="true" Size="Size.Small"
|
|
||||||
Color="@(context.IsActive ? Color.Success : Color.Warning)"
|
|
||||||
Variant="Variant.Filled">
|
|
||||||
@(context.IsActive ? "활성" : "비활성")
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Joined">
|
|
||||||
<MudText Typo="Typo.body2">@FormatDate(context.CreatedAt)</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Updated">
|
|
||||||
<MudText Typo="Typo.body2">@FormatDate(context.UpdatedAt)</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Actions">
|
|
||||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="@(() => EditUser(context))">편집</MudButton>
|
|
||||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteUser(context))">삭제</MudButton>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<!-- Add/Edit Dialog -->
|
|
||||||
<MudDialog @bind-Visible="_dialogVisible" Options="_dialogOptions">
|
|
||||||
<TitleContent>
|
|
||||||
<MudText Typo="Typo.h6">
|
|
||||||
<MudIcon Icon="@(_isEditMode ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="mr-3" />
|
|
||||||
@(_isEditMode ? "사용자 편집" : "새 사용자 추가")
|
|
||||||
</MudText>
|
|
||||||
</TitleContent>
|
|
||||||
<DialogContent>
|
|
||||||
<MudForm Model="@_formModel" @ref="_form">
|
|
||||||
<MudTextField T="string" @bind-Value="_formModel.Username" Label="사용자 ID" Required="true" Disabled="@_isEditMode"
|
|
||||||
RequiredError="사용자 ID를 입력해 주세요." Class="mb-3" />
|
|
||||||
|
|
||||||
<MudTextField T="string" @bind-Value="_formModel.Password" Label="@(_isEditMode ? "새 비밀번호 (미입력시 유지)" : "비밀번호")"
|
|
||||||
InputType="InputType.Password" Required="@(!_isEditMode)" RequiredError="비밀번호를 입력해 주세요." Class="mb-3" />
|
|
||||||
|
|
||||||
<MudSelect T="string" @bind-Value="_formModel.Role" Label="역할 권한" Required="true" Class="mb-3">
|
|
||||||
<MudSelectItem Value="@("Admin")">Admin (관리자)</MudSelectItem>
|
|
||||||
<MudSelectItem Value="@("Operator")">Operator (운영자)</MudSelectItem>
|
|
||||||
<MudSelectItem Value="@("Viewer")">Viewer (조회자)</MudSelectItem>
|
|
||||||
</MudSelect>
|
|
||||||
|
|
||||||
@if (_isEditMode)
|
|
||||||
{
|
|
||||||
<MudSwitch T="bool" @bind-Value="_formModel.IsActive" Color="Color.Success" Label="계정 활성화 상태" />
|
|
||||||
}
|
|
||||||
</MudForm>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CloseDialog" Class="px-5">취소</MudButton>
|
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveUser" Class="px-5">저장</MudButton>
|
|
||||||
</DialogActions>
|
|
||||||
</MudDialog>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<UserDto> _users = new();
|
|
||||||
private string SearchQuery = "";
|
|
||||||
private bool _dialogVisible;
|
|
||||||
private bool _isEditMode;
|
|
||||||
private MudForm _form = new();
|
|
||||||
private UserFormModel _formModel = new();
|
|
||||||
private DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true };
|
|
||||||
|
|
||||||
private IEnumerable<UserDto> FilteredUsers
|
|
||||||
{
|
|
||||||
get => string.IsNullOrEmpty(SearchQuery)
|
|
||||||
? _users
|
|
||||||
: _users.Where(u => u.Username.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadUsers()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// BaseAddress is set to HostEnvironment.BaseAddress by DI in Client/Program.cs.
|
|
||||||
// Never override it with a hardcoded port.
|
|
||||||
var res = await Http.GetFromJsonAsync<List<UserDto>>("api/users");
|
|
||||||
if (res != null)
|
|
||||||
{
|
|
||||||
_users = res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"사용자 목록 로드 실패: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenAddUserDialog()
|
|
||||||
{
|
|
||||||
_isEditMode = false;
|
|
||||||
_formModel = new UserFormModel { Role = "Viewer", IsActive = true };
|
|
||||||
_dialogVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EditUser(UserDto user)
|
|
||||||
{
|
|
||||||
_isEditMode = true;
|
|
||||||
_formModel = new UserFormModel
|
|
||||||
{
|
|
||||||
Username = user.Username,
|
|
||||||
Role = user.Role,
|
|
||||||
IsActive = user.IsActive,
|
|
||||||
Password = "" // Clear password field for security
|
|
||||||
};
|
|
||||||
_dialogVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteUser(UserDto user)
|
|
||||||
{
|
|
||||||
bool? result = await DialogService.ShowMessageBoxAsync(
|
|
||||||
"사용자 삭제",
|
|
||||||
$"정말로 사용자 '{user.Username}' 계정을 비활성화하시겠습니까?",
|
|
||||||
yesText: "비활성화", cancelText: "취소");
|
|
||||||
|
|
||||||
if (result == true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await Http.DeleteAsync($"api/users?username={user.Username}");
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Snackbar.Add("사용자 계정이 비활성화되었습니다.", Severity.Success);
|
|
||||||
await LoadUsers();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Snackbar.Add("계정 비활성화 작업에 실패했습니다.", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"API 에러: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseDialog()
|
|
||||||
{
|
|
||||||
_dialogVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveUser()
|
|
||||||
{
|
|
||||||
await _form.Validate();
|
|
||||||
if (!_form.IsValid) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
HttpResponseMessage response;
|
|
||||||
if (_isEditMode)
|
|
||||||
{
|
|
||||||
response = await Http.PutAsJsonAsync("api/users", _formModel);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
response = await Http.PostAsJsonAsync("api/users", _formModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Snackbar.Add("사용자 정보가 성공적으로 저장되었습니다.", Severity.Success);
|
|
||||||
_dialogVisible = false;
|
|
||||||
await LoadUsers();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var error = await response.Content.ReadAsStringAsync();
|
|
||||||
Snackbar.Add($"저장 실패: {error}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"API 오류 발생: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatDate(string isoString)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(isoString)) return "-";
|
|
||||||
if (DateTime.TryParse(isoString, out var dt))
|
|
||||||
{
|
|
||||||
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
|
||||||
}
|
|
||||||
return isoString;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UserDto
|
|
||||||
{
|
|
||||||
public string Username { get; set; } = string.Empty;
|
|
||||||
public string Role { get; set; } = string.Empty;
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
public string CreatedAt { get; set; } = string.Empty;
|
|
||||||
public string UpdatedAt { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UserFormModel
|
|
||||||
{
|
|
||||||
public string Username { get; set; } = string.Empty;
|
|
||||||
public string Password { get; set; } = string.Empty;
|
|
||||||
public string Role { get; set; } = "Viewer";
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using QuantEngine.Web.Client.Services;
|
|
||||||
using QuantEngine.Web.Client.Infrastructure;
|
|
||||||
using MudBlazor.Services;
|
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
|
||||||
|
|
||||||
// Register LocalStorage for cross-platform session persistence
|
|
||||||
builder.Services.AddScoped<LocalStorageService>();
|
|
||||||
|
|
||||||
// App State Service (RBAC & global state management)
|
|
||||||
builder.Services.AddScoped<AppStateService>();
|
|
||||||
|
|
||||||
// Authentication setup in WebAssembly client
|
|
||||||
builder.Services.AddAuthorizationCore();
|
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
|
||||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
|
||||||
|
|
||||||
// MudBlazor Services (CRITICAL: Required for Interactive WebAssembly)
|
|
||||||
builder.Services.AddMudServices();
|
|
||||||
|
|
||||||
// HttpClient register (API-First standard)
|
|
||||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
|
||||||
builder.Services.AddScoped<ApiClient>();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
|
||||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
|
||||||
<ProjectReference Include="..\..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" />
|
|
||||||
<PackageReference Include="MudBlazor" Version="9.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
@code {
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo("login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using QuantEngine.Core.Interfaces;
|
|
||||||
|
|
||||||
namespace QuantEngine.Web.Client.Services;
|
|
||||||
|
|
||||||
public class ApiClient
|
|
||||||
{
|
|
||||||
private readonly HttpClient _http;
|
|
||||||
private readonly ILogger<ApiClient> _logger;
|
|
||||||
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
|
|
||||||
{
|
|
||||||
_http = http;
|
|
||||||
// BaseAddress is set by the DI registration in Client/Program.cs via
|
|
||||||
// builder.HostEnvironment.BaseAddress — never hardcode a port here.
|
|
||||||
if (_http.BaseAddress == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"ApiClient: HttpClient.BaseAddress is null. " +
|
|
||||||
"Ensure the HttpClient is registered with HostEnvironment.BaseAddress in Client/Program.cs.");
|
|
||||||
}
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collection API Methods
|
|
||||||
|
|
||||||
public async Task<CollectionDashboardStateDto?> GetCollectionStateAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error fetching collection state");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CollectionRunsResponse?> GetCollectionRunsAsync(int limit = 20)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"api/collection/runs?limit={limit}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error fetching collection runs");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CollectionRunSnapshotsResponse?> GetCollectionSnapshotsAsync(string runId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"api/collection/runs/{runId}/snapshots");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error fetching snapshots for run {runId}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CollectionRunErrorsResponse?> GetCollectionErrorsAsync(string runId, int limit = 50)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"api/collection/runs/{runId}/errors?limit={limit}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error fetching errors for run {runId}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CollectionRunStartResponse?> StartCollectionRunAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _http.PostAsJsonAsync("api/collection/run", new { });
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return await response.Content.ReadFromJsonAsync<CollectionRunStartResponse>();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error starting collection run");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DTOs
|
|
||||||
|
|
||||||
public class CollectionDashboardStateDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("lastRunId")]
|
|
||||||
public string? LastRunId { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("lastRunStatus")]
|
|
||||||
public string? LastRunStatus { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("lastFinishedAt")]
|
|
||||||
public string? LastFinishedAt { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("totalSnapshots")]
|
|
||||||
public int TotalSnapshots { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("totalErrors")]
|
|
||||||
public int TotalErrors { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("recentErrors")]
|
|
||||||
public List<CollectionErrorDto> RecentErrors { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionRunDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runId")]
|
|
||||||
public string RunId { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("status")]
|
|
||||||
public string Status { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("startedAt")]
|
|
||||||
public string StartedAt { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("finishedAt")]
|
|
||||||
public string? FinishedAt { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("totalSnapshots")]
|
|
||||||
public int? TotalSnapshots { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("totalErrors")]
|
|
||||||
public int? TotalErrors { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionSnapshotDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runId")]
|
|
||||||
public string RunId { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("datasetName")]
|
|
||||||
public string DatasetName { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("ticker")]
|
|
||||||
public string Ticker { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("sourceName")]
|
|
||||||
public string SourceName { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("capturedAt")]
|
|
||||||
public string CapturedAt { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionErrorDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runId")]
|
|
||||||
public string RunId { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("sourceName")]
|
|
||||||
public string SourceName { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("errorKind")]
|
|
||||||
public string ErrorKind { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("errorMessage")]
|
|
||||||
public string ErrorMessage { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("ticker")]
|
|
||||||
public string Ticker { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionRunsResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runs")]
|
|
||||||
public List<CollectionRunDto> Runs { get; set; } = new();
|
|
||||||
|
|
||||||
[JsonPropertyName("count")]
|
|
||||||
public int Count { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionRunSnapshotsResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runId")]
|
|
||||||
public string RunId { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("snapshots")]
|
|
||||||
public List<CollectionSnapshotDto> Snapshots { get; set; } = new();
|
|
||||||
|
|
||||||
[JsonPropertyName("count")]
|
|
||||||
public int Count { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionRunErrorsResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runId")]
|
|
||||||
public string RunId { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("errors")]
|
|
||||||
public List<CollectionErrorDto> Errors { get; set; } = new();
|
|
||||||
|
|
||||||
[JsonPropertyName("count")]
|
|
||||||
public int Count { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CollectionRunStartResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("runId")]
|
|
||||||
public string RunId { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("status")]
|
|
||||||
public string Status { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("startedAt")]
|
|
||||||
public string StartedAt { get; set; } = "";
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
namespace QuantEngine.Web.Client.Services;
|
|
||||||
|
|
||||||
public class AppStateService
|
|
||||||
{
|
|
||||||
private UserContext _currentUser;
|
|
||||||
private List<string> _userRoles = new();
|
|
||||||
private bool _isInitialized = false;
|
|
||||||
|
|
||||||
public event Action OnStateChanged;
|
|
||||||
|
|
||||||
public UserContext CurrentUser
|
|
||||||
{
|
|
||||||
get => _currentUser;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_currentUser = value;
|
|
||||||
NotifyStateChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<string> UserRoles
|
|
||||||
{
|
|
||||||
get => _userRoles;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_userRoles = value;
|
|
||||||
NotifyStateChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsInitialized
|
|
||||||
{
|
|
||||||
get => _isInitialized;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_isInitialized = value;
|
|
||||||
NotifyStateChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public AppStateService()
|
|
||||||
{
|
|
||||||
_currentUser = new UserContext();
|
|
||||||
_userRoles = new List<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initialize app state from current user context
|
|
||||||
/// </summary>
|
|
||||||
public async Task InitializeAsync(HttpClient httpClient)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await httpClient.GetAsync("api/auth/user");
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
|
||||||
// Parse user info (implement as needed)
|
|
||||||
CurrentUser = new UserContext { Name = "Admin", Email = "admin@quantengine.local" };
|
|
||||||
UserRoles = new List<string> { "Admin" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if user has specific role (RBAC)
|
|
||||||
/// </summary>
|
|
||||||
public bool HasRole(string role)
|
|
||||||
{
|
|
||||||
return UserRoles.Contains(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if user has any of the specified roles
|
|
||||||
/// </summary>
|
|
||||||
public bool HasAnyRole(params string[] roles)
|
|
||||||
{
|
|
||||||
return roles.Any(r => UserRoles.Contains(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if user has all specified roles
|
|
||||||
/// </summary>
|
|
||||||
public bool HasAllRoles(params string[] roles)
|
|
||||||
{
|
|
||||||
return roles.All(r => UserRoles.Contains(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear user state
|
|
||||||
/// </summary>
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
CurrentUser = new UserContext();
|
|
||||||
UserRoles = new List<string>();
|
|
||||||
IsInitialized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NotifyStateChanged() => OnStateChanged?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User context model
|
|
||||||
/// </summary>
|
|
||||||
public class UserContext
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = "";
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
public string Email { get; set; } = "";
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API Response wrapper
|
|
||||||
/// </summary>
|
|
||||||
public class ApiResponse<T>
|
|
||||||
{
|
|
||||||
public bool Success { get; set; }
|
|
||||||
public string Message { get; set; }
|
|
||||||
public T Data { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pagination model
|
|
||||||
/// </summary>
|
|
||||||
public class PaginatedResponse<T>
|
|
||||||
{
|
|
||||||
public List<T> Items { get; set; }
|
|
||||||
public int PageNumber { get; set; }
|
|
||||||
public int PageSize { get; set; }
|
|
||||||
public int TotalCount { get; set; }
|
|
||||||
public int TotalPages => (TotalCount + PageSize - 1) / PageSize;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using Microsoft.JSInterop;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace QuantEngine.Web.Client.Services
|
|
||||||
{
|
|
||||||
public class LocalStorageService
|
|
||||||
{
|
|
||||||
private readonly IJSRuntime _js;
|
|
||||||
|
|
||||||
public LocalStorageService(IJSRuntime js)
|
|
||||||
{
|
|
||||||
_js = js;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SetAsync<T>(string key, T value)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(value);
|
|
||||||
await _js.InvokeVoidAsync("localStorage.setItem", key, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<T?> GetAsync<T>(string key)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
|
|
||||||
if (string.IsNullOrEmpty(json))
|
|
||||||
{
|
|
||||||
return default;
|
|
||||||
}
|
|
||||||
return JsonSerializer.Deserialize<T>(json);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(string key)
|
|
||||||
{
|
|
||||||
await _js.InvokeVoidAsync("localStorage.removeItem", key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
using MudBlazor;
|
|
||||||
|
|
||||||
namespace QuantEngine.Web.Client.Theme;
|
|
||||||
|
|
||||||
public static class AppTheme
|
|
||||||
{
|
|
||||||
public static MudTheme LightTheme => new()
|
|
||||||
{
|
|
||||||
PaletteLight = new PaletteLight
|
|
||||||
{
|
|
||||||
Primary = "#3f51b5",
|
|
||||||
Secondary = "#f50057",
|
|
||||||
Success = "#4caf50",
|
|
||||||
Warning = "#ff9800",
|
|
||||||
Error = "#f44336",
|
|
||||||
Info = "#2196f3",
|
|
||||||
Dark = "#121212",
|
|
||||||
Background = "#fafafa",
|
|
||||||
Surface = "#ffffff",
|
|
||||||
TextPrimary = "#212121",
|
|
||||||
TextSecondary = "rgba(0,0,0,0.6)",
|
|
||||||
DrawerBackground = "#ffffff",
|
|
||||||
DrawerText = "#212121",
|
|
||||||
AppbarBackground = "#3f51b5",
|
|
||||||
AppbarText = "#ffffff",
|
|
||||||
ActionDefault = "#c0c0c0",
|
|
||||||
ActionDisabled = "#f5f5f5",
|
|
||||||
ActionDisabledBackground = "rgba(0,0,0,0.12)",
|
|
||||||
Divider = "#e0e0e0",
|
|
||||||
DividerLight = "#f5f5f5",
|
|
||||||
TableLines = "#e0e0e0",
|
|
||||||
LinesDefault = "#e0e0e0",
|
|
||||||
LinesInputs = "#bdbdbd",
|
|
||||||
TextDisabled = "rgba(0,0,0,0.38)"
|
|
||||||
},
|
|
||||||
Typography = new Typography
|
|
||||||
{
|
|
||||||
Default = new DefaultTypography
|
|
||||||
{
|
|
||||||
FontFamily = new[] { "Roboto", "sans-serif" },
|
|
||||||
FontSize = "1rem",
|
|
||||||
FontWeight = "400",
|
|
||||||
LineHeight = "1.5",
|
|
||||||
LetterSpacing = "0.5px"
|
|
||||||
},
|
|
||||||
H1 = new H1Typography
|
|
||||||
{
|
|
||||||
FontSize = "6rem",
|
|
||||||
FontWeight = "300",
|
|
||||||
LineHeight = "1.167",
|
|
||||||
LetterSpacing = "-0.015625em"
|
|
||||||
},
|
|
||||||
H2 = new H2Typography
|
|
||||||
{
|
|
||||||
FontSize = "3.75rem",
|
|
||||||
FontWeight = "300",
|
|
||||||
LineHeight = "1.2",
|
|
||||||
LetterSpacing = "-0.0083333333em"
|
|
||||||
},
|
|
||||||
H3 = new H3Typography
|
|
||||||
{
|
|
||||||
FontSize = "3rem",
|
|
||||||
FontWeight = "400",
|
|
||||||
LineHeight = "1.167",
|
|
||||||
LetterSpacing = "0em"
|
|
||||||
},
|
|
||||||
H4 = new H4Typography
|
|
||||||
{
|
|
||||||
FontSize = "2.125rem",
|
|
||||||
FontWeight = "500",
|
|
||||||
LineHeight = "1.235",
|
|
||||||
LetterSpacing = "0.0125em"
|
|
||||||
},
|
|
||||||
H5 = new H5Typography
|
|
||||||
{
|
|
||||||
FontSize = "1.5rem",
|
|
||||||
FontWeight = "500",
|
|
||||||
LineHeight = "1.334",
|
|
||||||
LetterSpacing = "0em"
|
|
||||||
},
|
|
||||||
H6 = new H6Typography
|
|
||||||
{
|
|
||||||
FontSize = "1.25rem",
|
|
||||||
FontWeight = "600",
|
|
||||||
LineHeight = "1.6",
|
|
||||||
LetterSpacing = "0.0125em"
|
|
||||||
},
|
|
||||||
Body1 = new Body1Typography
|
|
||||||
{
|
|
||||||
FontSize = "1rem",
|
|
||||||
FontWeight = "500",
|
|
||||||
LineHeight = "1.5",
|
|
||||||
LetterSpacing = "0.03125em"
|
|
||||||
},
|
|
||||||
Body2 = new Body2Typography
|
|
||||||
{
|
|
||||||
FontSize = "0.875rem",
|
|
||||||
FontWeight = "400",
|
|
||||||
LineHeight = "1.43",
|
|
||||||
LetterSpacing = "0.0178571429em"
|
|
||||||
},
|
|
||||||
Button = new ButtonTypography
|
|
||||||
{
|
|
||||||
FontSize = "0.875rem",
|
|
||||||
FontWeight = "600",
|
|
||||||
LineHeight = "1.75",
|
|
||||||
LetterSpacing = "0.0892857143em"
|
|
||||||
},
|
|
||||||
Caption = new CaptionTypography
|
|
||||||
{
|
|
||||||
FontSize = "0.75rem",
|
|
||||||
FontWeight = "400",
|
|
||||||
LineHeight = "1.66",
|
|
||||||
LetterSpacing = "0.0333333333em"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
LayoutProperties = new LayoutProperties
|
|
||||||
{
|
|
||||||
DefaultBorderRadius = "4px",
|
|
||||||
DrawerWidthLeft = "256px",
|
|
||||||
DrawerWidthRight = "256px",
|
|
||||||
AppbarHeight = "64px",
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public static MudTheme DarkTheme => new()
|
|
||||||
{
|
|
||||||
PaletteDark = new PaletteDark
|
|
||||||
{
|
|
||||||
Primary = "#bb86fc",
|
|
||||||
Secondary = "#03dac6",
|
|
||||||
Success = "#4caf50",
|
|
||||||
Warning = "#ff9800",
|
|
||||||
Error = "#cf6679",
|
|
||||||
Info = "#2196f3",
|
|
||||||
Dark = "#121212",
|
|
||||||
Background = "#121212",
|
|
||||||
Surface = "#1e1e1e",
|
|
||||||
TextPrimary = "#ffffff",
|
|
||||||
TextSecondary = "rgba(255,255,255,0.7)",
|
|
||||||
DrawerBackground = "#1e1e1e",
|
|
||||||
DrawerText = "#ffffff",
|
|
||||||
AppbarBackground = "#1f1f1f",
|
|
||||||
AppbarText = "#ffffff",
|
|
||||||
ActionDefault = "#3f3f3f",
|
|
||||||
ActionDisabled = "#1e1e1e",
|
|
||||||
ActionDisabledBackground = "rgba(255,255,255,0.12)",
|
|
||||||
Divider = "#37474f",
|
|
||||||
DividerLight = "#2c3e50",
|
|
||||||
TableLines = "#37474f",
|
|
||||||
LinesDefault = "#37474f",
|
|
||||||
LinesInputs = "#555555",
|
|
||||||
TextDisabled = "rgba(255,255,255,0.38)"
|
|
||||||
},
|
|
||||||
Typography = LightTheme.Typography,
|
|
||||||
LayoutProperties = LightTheme.LayoutProperties
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@using System.Net.Http
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
|
||||||
@using Microsoft.JSInterop
|
|
||||||
@using MudBlazor
|
|
||||||
@using QuantEngine.Web.Client
|
|
||||||
@using QuantEngine.Web.Client.Pages
|
|
||||||
@using QuantEngine.Web.Client.Layout
|
|
||||||
@using QuantEngine.Web.Client.Infrastructure
|
|
||||||
@using QuantEngine.Web.Client.Services
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@using Microsoft.AspNetCore.Authorization
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
/* QuantEngine Global Styles */
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--mud-palette-text-primary, #212121);
|
|
||||||
background-color: var(--mud-palette-background, #fafafa);
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: var(--mud-palette-surface, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--mud-palette-action-default, #c0c0c0);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--mud-palette-primary, #3f51b5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text Utilities */
|
|
||||||
.text-primary {
|
|
||||||
color: var(--mud-palette-primary, #3f51b5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: var(--mud-palette-secondary, #f50057);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: var(--mud-palette-success, #4caf50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-warning {
|
|
||||||
color: var(--mud-palette-warning, #ff9800);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-error {
|
|
||||||
color: var(--mud-palette-error, #f44336);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--mud-palette-text-secondary, rgba(0,0,0,0.6));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spacing Utilities */
|
|
||||||
.mt-1 { margin-top: 0.25rem; }
|
|
||||||
.mt-2 { margin-top: 0.5rem; }
|
|
||||||
.mt-3 { margin-top: 1rem; }
|
|
||||||
.mt-4 { margin-top: 1.5rem; }
|
|
||||||
.mt-5 { margin-top: 3rem; }
|
|
||||||
|
|
||||||
.mb-1 { margin-bottom: 0.25rem; }
|
|
||||||
.mb-2 { margin-bottom: 0.5rem; }
|
|
||||||
.mb-3 { margin-bottom: 1rem; }
|
|
||||||
.mb-4 { margin-bottom: 1.5rem; }
|
|
||||||
.mb-5 { margin-bottom: 3rem; }
|
|
||||||
|
|
||||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
|
||||||
.my-auto { margin-top: auto; margin-bottom: auto; }
|
|
||||||
|
|
||||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
|
||||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
|
||||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
|
||||||
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
|
||||||
|
|
||||||
/* Flex Utilities */
|
|
||||||
.d-flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-column {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-content-center {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-content-between {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gap Utilities */
|
|
||||||
.gap-1 { gap: 0.25rem; }
|
|
||||||
.gap-2 { gap: 0.5rem; }
|
|
||||||
.gap-3 { gap: 1rem; }
|
|
||||||
.gap-4 { gap: 1.5rem; }
|
|
||||||
|
|
||||||
/* Loading Skeleton */
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--mud-palette-surface, #fff) 0%,
|
|
||||||
var(--mud-palette-divider, #e0e0e0) 50%,
|
|
||||||
var(--mud-palette-surface, #fff) 100%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loading {
|
|
||||||
0% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MudBlazor Overrides */
|
|
||||||
.mud-appbar {
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-drawer {
|
|
||||||
border-right: 1px solid var(--mud-palette-divider, #e0e0e0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-drawer-content {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-nav-link {
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-nav-link:hover {
|
|
||||||
background-color: var(--mud-palette-action-default-hover, rgba(0, 0, 0, 0.04));
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-nav-link.mud-ripple-nav-link-active {
|
|
||||||
background-color: var(--mud-palette-primary-lighten, rgba(63, 81, 181, 0.1));
|
|
||||||
color: var(--mud-palette-primary, #3f51b5);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-card {
|
|
||||||
border: 1px solid var(--mud-palette-divider, #e0e0e0);
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-card:hover {
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-button {
|
|
||||||
text-transform: none;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-button-root:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Forms */
|
|
||||||
.mud-input-control {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-input-label {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-input {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-input.mud-input-text {
|
|
||||||
background-color: var(--mud-palette-surface, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
.mud-table {
|
|
||||||
background-color: var(--mud-palette-surface, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-table-head {
|
|
||||||
background-color: var(--mud-palette-background, #fafafa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-table-row:hover {
|
|
||||||
background-color: var(--mud-palette-action-default-hover, rgba(0, 0, 0, 0.04));
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-table-cell {
|
|
||||||
padding: 1rem;
|
|
||||||
border-color: var(--mud-palette-divider, #e0e0e0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
body {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-drawer {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 90% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-appbar {
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mud-table-cell {
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation Classes */
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.3s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-in {
|
|
||||||
animation: slideIn 0.3s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(10px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessibility */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
* {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Print Styles */
|
|
||||||
@media print {
|
|
||||||
.mud-appbar,
|
|
||||||
.mud-drawer,
|
|
||||||
.no-print {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
|
using Hangfire;
|
||||||
|
using QuantEngine.Application.Interfaces;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
namespace QuantEngine.Web.Endpoints;
|
namespace QuantEngine.Web.Endpoints;
|
||||||
@@ -214,20 +216,52 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StartCollectionRunEndpoint : EndpointWithoutRequest
|
public class StartCollectionRunResponse
|
||||||
{
|
{
|
||||||
|
public string RunId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StartCollectionRunEndpoint : EndpointWithoutRequest<StartCollectionRunResponse>
|
||||||
|
{
|
||||||
|
private readonly IBackgroundJobClient _jobClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<StartCollectionRunEndpoint> _logger;
|
||||||
|
|
||||||
|
public StartCollectionRunEndpoint(
|
||||||
|
IBackgroundJobClient jobClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<StartCollectionRunEndpoint> logger)
|
||||||
|
{
|
||||||
|
_jobClient = jobClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/collection/run");
|
Post("/api/collection/run");
|
||||||
AllowAnonymous();
|
|
||||||
Description(d => d
|
Description(d => d
|
||||||
.Produces(202)
|
.Produces<StartCollectionRunResponse>(202)
|
||||||
.Produces(500));
|
.Produces(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Return 202 Accepted status code via generic status code handler
|
try
|
||||||
await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted());
|
{
|
||||||
|
var runId = $"api-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||||
|
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||||
|
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
|
||||||
|
|
||||||
|
_jobClient.Enqueue<ICollectionOrchestrator>(o => o.RunCollectionAsync(runId, accountMode, tickers));
|
||||||
|
|
||||||
|
_logger.LogInformation("Collection run {RunId} enqueued", runId);
|
||||||
|
|
||||||
|
await SendAsync(new StartCollectionRunResponse { RunId = runId }, 202, ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await SendErrorsAsync(500, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,7 @@
|
|||||||
|
|
||||||
<div class="login-footer">
|
<div class="login-footer">
|
||||||
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
|
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
|
||||||
|
<p style="font-size: 11px; margin-top: 4px; opacity: 0.8;">Version: @Model.AppVersion</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Reflection;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -17,6 +18,79 @@ public class LoginModel : PageModel
|
|||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public bool RememberUsername { get; set; }
|
public bool RememberUsername { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string AppVersion
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// 1. Try reading version.txt from application base directory
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var txtPath = Path.Combine(AppContext.BaseDirectory, "version.txt");
|
||||||
|
if (System.IO.File.Exists(txtPath))
|
||||||
|
{
|
||||||
|
var txtVersion = System.IO.File.ReadAllText(txtPath).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(txtVersion))
|
||||||
|
return txtVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
// 2. Try reading raw assembly version
|
||||||
|
var rawVersion = Assembly.GetEntryAssembly()
|
||||||
|
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
|
?.InformationalVersion;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(rawVersion) && rawVersion.StartsWith("quant_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return rawVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to dynamic local Git parsing if available
|
||||||
|
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
||||||
|
string gitHash = "024122d";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var current = new DirectoryInfo(baseDir);
|
||||||
|
string? repoRoot = null;
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||||
|
{
|
||||||
|
repoRoot = current.FullName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
if (repoRoot != null)
|
||||||
|
{
|
||||||
|
var headPath = Path.Combine(repoRoot, ".git", "HEAD");
|
||||||
|
if (System.IO.File.Exists(headPath))
|
||||||
|
{
|
||||||
|
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
||||||
|
if (headContent.StartsWith("ref:"))
|
||||||
|
{
|
||||||
|
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim().Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (System.IO.File.Exists(refPath))
|
||||||
|
{
|
||||||
|
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
||||||
|
if (fullHash.Length >= 7)
|
||||||
|
gitHash = fullHash.Substring(0, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (headContent.Length >= 7)
|
||||||
|
{
|
||||||
|
gitHash = headContent.Substring(0, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
int runCount = 11;
|
||||||
|
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public LoginModel(AuthService authService, IIpLockoutService lockoutService, ILogger<LoginModel> logger)
|
public LoginModel(AuthService authService, IIpLockoutService lockoutService, ILogger<LoginModel> logger)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "{runId}"
|
@page "{runId?}"
|
||||||
@model QuantEngine.Web.Pages.Admin.Collection.DetailModel
|
@model QuantEngine.Web.Pages.Admin.Collection.DetailModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "수집 실행 상세";
|
ViewData["Title"] = "수집 실행 상세";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
using QuantEngine.Web.Services;
|
using QuantEngine.Web.Services;
|
||||||
@@ -19,16 +20,31 @@ public class DetailModel : PageModel
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnGetAsync(string runId)
|
public async Task OnGetAsync([FromQuery] string? runId, [FromRoute] string? routeRunId)
|
||||||
{
|
{
|
||||||
|
var targetRunId = runId ?? routeRunId;
|
||||||
|
if (string.IsNullOrEmpty(targetRunId))
|
||||||
|
{
|
||||||
|
if (RouteData.Values.TryGetValue("runId", out var routeVal))
|
||||||
|
{
|
||||||
|
targetRunId = routeVal?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(targetRunId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Detail page loaded without a runId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100);
|
var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100);
|
||||||
Run = runs.FirstOrDefault(r => r.RunId == runId);
|
Run = runs.FirstOrDefault(r => r.RunId == targetRunId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to load collection run detail");
|
_logger.LogError(ex, "Failed to load collection run detail for runId: {RunId}", targetRunId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@page
|
||||||
|
@model QuantEngine.Web.Pages.Admin.Collection.StartModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "수집 시작";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">데이터 수집 시작</h2>
|
||||||
|
<div class="text-muted">Hangfire 백그라운드 작업으로 수집을 시작합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@Model.ErrorMessage</div>
|
||||||
|
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">수집 작업이 등록되었습니다. 실행 ID: <code>@Model.RunId</code></div>
|
||||||
|
<a href="/Admin/Collection/Detail?runId=@Uri.EscapeDataString(Model.RunId!)" class="btn btn-primary">실행 상세 보기</a>
|
||||||
|
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using QuantEngine.Application.Interfaces;
|
||||||
|
using QuantEngine.Web.Services;
|
||||||
|
|
||||||
|
namespace QuantEngine.Web.Pages.Admin.Collection;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||||
|
public class StartModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly IBackgroundJobClient _jobClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<StartModel> _logger;
|
||||||
|
|
||||||
|
public string? RunId { get; private set; }
|
||||||
|
public string? ErrorMessage { get; private set; }
|
||||||
|
|
||||||
|
public StartModel(
|
||||||
|
IBackgroundJobClient jobClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<StartModel> logger)
|
||||||
|
{
|
||||||
|
_jobClient = jobClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunId = $"admin-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||||
|
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||||
|
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
|
||||||
|
|
||||||
|
_jobClient.Enqueue<ICollectionOrchestrator>(orchestrator =>
|
||||||
|
orchestrator.RunCollectionAsync(RunId!, accountMode, tickers));
|
||||||
|
|
||||||
|
_logger.LogInformation("Collection run {RunId} enqueued from admin page", RunId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to enqueue collection run from admin page");
|
||||||
|
ErrorMessage = "수집 작업을 등록하지 못했습니다. Hangfire 상태와 서버 로그를 확인하세요.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,10 +87,14 @@
|
|||||||
<span class="text-muted">환경:</span>
|
<span class="text-muted">환경:</span>
|
||||||
<strong>@Model.EnvironmentName</strong>
|
<strong>@Model.EnvironmentName</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-2">
|
||||||
<span class="text-muted">데이터베이스:</span>
|
<span class="text-muted">데이터베이스:</span>
|
||||||
<strong>@(Model.IsDatabaseConnected ? "연결됨" : "연결 끊김")</strong>
|
<strong>@(Model.IsDatabaseConnected ? "연결됨" : "연결 끊김")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted">배포 버전:</span>
|
||||||
|
<strong>@Model.AppVersion</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
@@ -17,6 +19,79 @@ public class IndexModel : PageModel
|
|||||||
public int? RecentRunsCount { get; set; }
|
public int? RecentRunsCount { get; set; }
|
||||||
public bool IsDatabaseConnected { get; set; }
|
public bool IsDatabaseConnected { get; set; }
|
||||||
public string EnvironmentName => _environment.EnvironmentName;
|
public string EnvironmentName => _environment.EnvironmentName;
|
||||||
|
public string AppVersion
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// 1. Try reading version.txt from application base directory
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var txtPath = Path.Combine(AppContext.BaseDirectory, "version.txt");
|
||||||
|
if (System.IO.File.Exists(txtPath))
|
||||||
|
{
|
||||||
|
var txtVersion = System.IO.File.ReadAllText(txtPath).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(txtVersion))
|
||||||
|
return txtVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
// 2. Try reading raw assembly version
|
||||||
|
var rawVersion = Assembly.GetEntryAssembly()
|
||||||
|
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
|
?.InformationalVersion;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(rawVersion) && rawVersion.StartsWith("quant_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return rawVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to dynamic local Git parsing if available
|
||||||
|
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
||||||
|
string gitHash = "024122d";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var current = new DirectoryInfo(baseDir);
|
||||||
|
string? repoRoot = null;
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||||
|
{
|
||||||
|
repoRoot = current.FullName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
if (repoRoot != null)
|
||||||
|
{
|
||||||
|
var headPath = Path.Combine(repoRoot, ".git", "HEAD");
|
||||||
|
if (System.IO.File.Exists(headPath))
|
||||||
|
{
|
||||||
|
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
||||||
|
if (headContent.StartsWith("ref:"))
|
||||||
|
{
|
||||||
|
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim().Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (System.IO.File.Exists(refPath))
|
||||||
|
{
|
||||||
|
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
||||||
|
if (fullHash.Length >= 7)
|
||||||
|
gitHash = fullHash.Substring(0, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (headContent.Length >= 7)
|
||||||
|
{
|
||||||
|
gitHash = headContent.Substring(0, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
int runCount = 11;
|
||||||
|
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public IndexModel(
|
public IndexModel(
|
||||||
IWorkspaceRepository workspaceRepository,
|
IWorkspaceRepository workspaceRepository,
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
@page
|
||||||
|
@model QuantEngine.Web.Pages.Admin.Database.IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "DB 테이블 관리";
|
||||||
|
Layout = "_AdminLayout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Left panel: Table list -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">데이터베이스 테이블 목록</h3>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush" style="max-height: 700px; overflow-y: auto;">
|
||||||
|
@foreach (var table in Model.TableList)
|
||||||
|
{
|
||||||
|
var parts = table.Split('.');
|
||||||
|
var schema = parts[0];
|
||||||
|
var name = parts[1];
|
||||||
|
var isActive = Model.SelectedTable == table ? "active" : "";
|
||||||
|
|
||||||
|
<a href="/Admin/Database?tableName=@table" class="list-group-item list-group-item-action @isActive d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small">@schema.ToUpperInvariant().</span><strong>@name</strong>
|
||||||
|
</div>
|
||||||
|
<i class="ti ti-chevron-right text-muted"></i>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel: Selected table data and CRUD actions -->
|
||||||
|
<div class="col-md-9">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div><i class="ti ti-alert-triangle me-2"></i></div>
|
||||||
|
<div>@Model.ErrorMessage</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-dismissible" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div><i class="ti ti-circle-check me-2"></i></div>
|
||||||
|
<div>@Model.SuccessMessage</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SelectedTable))
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">@Model.SelectedTable 데이터 조회</h3>
|
||||||
|
<p class="card-subtitle text-muted">상위 100개 데이터 행을 출력합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#modal-add-row">
|
||||||
|
<i class="ti ti-plus me-1"></i> 새 데이터 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" style="max-height: 600px;">
|
||||||
|
<table class="table table-vcenter table-mobile-md card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
<th>
|
||||||
|
@col
|
||||||
|
@if (col == Model.PrimaryKeyColumn)
|
||||||
|
{
|
||||||
|
<span class="badge bg-purple-lt ms-1">PK</span>
|
||||||
|
}
|
||||||
|
</th>
|
||||||
|
}
|
||||||
|
<th class="w-1">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Model.Rows.Count > 0)
|
||||||
|
{
|
||||||
|
@foreach (var row in Model.Rows)
|
||||||
|
{
|
||||||
|
var pkVal = Model.PrimaryKeyColumn != null && row.ContainsKey(Model.PrimaryKeyColumn)
|
||||||
|
? row[Model.PrimaryKeyColumn]?.ToString() ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
<td data-label="@col">
|
||||||
|
<span class="text-wrap">@row[col]</span>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
<div class="btn-list flex-nowrap">
|
||||||
|
<button class="btn btn-sm btn-outline-primary edit-row-btn"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modal-edit-row"
|
||||||
|
data-pk-val="@pkVal"
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
@:data-field-@col="@row[col]"
|
||||||
|
}>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="@(Model.ColumnNames.Count + 1)" class="text-center text-muted py-4">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-3 text-muted">
|
||||||
|
<i class="ti ti-database-off" style="font-size: 3rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h3>선택된 테이블이 없습니다</h3>
|
||||||
|
<p class="text-muted">좌측 목록에서 조회 및 수정을 원하는 테이블을 선택해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SelectedTable))
|
||||||
|
{
|
||||||
|
<!-- Modal: Add Row -->
|
||||||
|
<div class="modal modal-blur fade" id="modal-add-row" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" asp-page-handler="AddRow">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">새 데이터 행 추가 (@Model.SelectedTable)</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
var isPk = col == Model.PrimaryKeyColumn;
|
||||||
|
var isSerial = col == "id" && Model.SelectedTable.Contains("workspace_change_log");
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
@col
|
||||||
|
@if (isPk) { <span class="text-danger">* (PK)</span> }
|
||||||
|
</label>
|
||||||
|
@if (isSerial)
|
||||||
|
{
|
||||||
|
<input type="text" class="form-control" placeholder="자동 생성 (SERIAL)" disabled />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="text" class="form-control" name="@col" placeholder="@col 값을 입력하세요" required="@isPk" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="submit" class="btn btn-primary ms-auto">저장하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Edit Row -->
|
||||||
|
<div class="modal modal-blur fade" id="modal-edit-row" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" asp-page-handler="SaveRow">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
|
||||||
|
<input type="hidden" name="pkColumn" value="@Model.PrimaryKeyColumn" />
|
||||||
|
<input type="hidden" name="pkValue" id="edit-pk-value" />
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">데이터 행 수정 (@Model.SelectedTable)</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row" id="edit-fields-container">
|
||||||
|
@foreach (var col in Model.ColumnNames)
|
||||||
|
{
|
||||||
|
var isPk = col == Model.PrimaryKeyColumn;
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
@col
|
||||||
|
@if (isPk) { <span class="text-muted">(PK - 수정 불가)</span> }
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" name="@col" id="edit-field-input-@col" @(isPk ? "readonly" : "") />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="submit" class="btn btn-primary ms-auto">저장하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const editButtons = document.querySelectorAll(".edit-row-btn");
|
||||||
|
editButtons.forEach(btn => {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
const pkVal = this.getAttribute("data-pk-val");
|
||||||
|
document.getElementById("edit-pk-value").value = pkVal;
|
||||||
|
|
||||||
|
// Populate fields dynamically
|
||||||
|
Array.from(this.attributes).forEach(attr => {
|
||||||
|
if (attr.name.startsWith("data-field-")) {
|
||||||
|
const fieldName = attr.name.substring("data-field-".length);
|
||||||
|
const inputEl = document.getElementById("edit-field-input-" + fieldName);
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.value = attr.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace QuantEngine.Web.Pages.Admin.Database
|
||||||
|
{
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
|
private readonly ILogger<IndexModel> _logger;
|
||||||
|
|
||||||
|
public List<string> TableList { get; set; } = new();
|
||||||
|
public string? SelectedTable { get; set; }
|
||||||
|
public List<string> ColumnNames { get; set; } = new();
|
||||||
|
public List<Dictionary<string, object>> Rows { get; set; } = new();
|
||||||
|
public string? PrimaryKeyColumn { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string? ActionTableName { get; set; }
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string? ActionRowKey { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string? SuccessMessage { get; set; }
|
||||||
|
|
||||||
|
public IndexModel(IDbConnectionFactory connectionFactory, ILogger<IndexModel> logger)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnGetAsync(string? tableName)
|
||||||
|
{
|
||||||
|
await LoadTableListAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tableName))
|
||||||
|
{
|
||||||
|
// Validate table name is in whitelist to prevent SQL Injection
|
||||||
|
if (TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
SelectedTable = tableName;
|
||||||
|
await LoadTableDataAsync(tableName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessage = "허용되지 않은 테이블명입니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveRowAsync()
|
||||||
|
{
|
||||||
|
await LoadTableListAsync();
|
||||||
|
|
||||||
|
var tableName = Request.Form["tableName"].ToString();
|
||||||
|
var pkColumn = Request.Form["pkColumn"].ToString();
|
||||||
|
var pkValue = Request.Form["pkValue"].ToString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(tableName) || !TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ErrorMessage = "유효하지 않은 테이블입니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
// Load target columns to update
|
||||||
|
var columns = new List<string>();
|
||||||
|
var parameters = new List<NpgsqlParameter>();
|
||||||
|
|
||||||
|
foreach (var key in Request.Form.Keys)
|
||||||
|
{
|
||||||
|
if (key == "tableName" || key == "pkColumn" || key == "pkValue" || key == "__RequestVerificationToken")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var val = Request.Form[key].ToString();
|
||||||
|
columns.Add($"\"{key}\" = @{key}");
|
||||||
|
|
||||||
|
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
param.Value = (object?)val ?? DBNull.Value;
|
||||||
|
parameters.Add(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.Count > 0 && !string.IsNullOrEmpty(pkColumn))
|
||||||
|
{
|
||||||
|
var sql = $"UPDATE {tableName} SET {string.Join(", ", columns)} WHERE \"{pkColumn}\" = @pk_val";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||||
|
foreach (var p in parameters) cmd.Parameters.Add(p);
|
||||||
|
|
||||||
|
var pkParam = new NpgsqlParameter("@pk_val", NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
pkParam.Value = pkValue;
|
||||||
|
cmd.Parameters.Add(pkParam);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
SuccessMessage = "행 데이터가 성공적으로 수정되었습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update row for {Table}", tableName);
|
||||||
|
ErrorMessage = $"저장 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage(new { tableName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAddRowAsync()
|
||||||
|
{
|
||||||
|
await LoadTableListAsync();
|
||||||
|
|
||||||
|
var tableName = Request.Form["tableName"].ToString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(tableName) || !TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ErrorMessage = "유효하지 않은 테이블입니다.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
var colNames = new List<string>();
|
||||||
|
var paramNames = new List<string>();
|
||||||
|
var parameters = new List<NpgsqlParameter>();
|
||||||
|
|
||||||
|
foreach (var key in Request.Form.Keys)
|
||||||
|
{
|
||||||
|
if (key == "tableName" || key == "__RequestVerificationToken")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var val = Request.Form[key].ToString();
|
||||||
|
colNames.Add($"\"{key}\"");
|
||||||
|
paramNames.Add($"@{key}");
|
||||||
|
|
||||||
|
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
param.Value = (object?)val ?? DBNull.Value;
|
||||||
|
parameters.Add(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colNames.Count > 0)
|
||||||
|
{
|
||||||
|
var sql = $"INSERT INTO {tableName} ({string.Join(", ", colNames)}) VALUES ({string.Join(", ", paramNames)})";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||||
|
foreach (var p in parameters) cmd.Parameters.Add(p);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
SuccessMessage = "새 데이터 행이 성공적으로 추가되었습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to insert row for {Table}", tableName);
|
||||||
|
ErrorMessage = $"추가 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage(new { tableName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTableListAsync()
|
||||||
|
{
|
||||||
|
TableList.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
var sql = @"
|
||||||
|
SELECT table_schema || '.' || table_name AS full_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema IN ('quantengine', 'engine_history')
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_schema, table_name;";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
TableList.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load database table list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTableDataAsync(string tableName)
|
||||||
|
{
|
||||||
|
ColumnNames.Clear();
|
||||||
|
Rows.Clear();
|
||||||
|
PrimaryKeyColumn = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) conn.Open();
|
||||||
|
|
||||||
|
var parts = tableName.Split('.');
|
||||||
|
var schema = parts[0];
|
||||||
|
var tableOnly = parts[1];
|
||||||
|
|
||||||
|
var pkSql = @"
|
||||||
|
SELECT a.attname
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indrelid = @table_name::regclass
|
||||||
|
AND i.indisprimary;";
|
||||||
|
|
||||||
|
using (var pkCmd = new NpgsqlCommand(pkSql, (NpgsqlConnection)conn))
|
||||||
|
{
|
||||||
|
pkCmd.Parameters.AddWithValue("@table_name", tableName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pkResult = await pkCmd.ExecuteScalarAsync();
|
||||||
|
if (pkResult != null) PrimaryKeyColumn = pkResult.ToString();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(PrimaryKeyColumn))
|
||||||
|
{
|
||||||
|
if (tableName.Contains("settings")) PrimaryKeyColumn = "key";
|
||||||
|
else if (tableName.Contains("workspace_account")) PrimaryKeyColumn = "username";
|
||||||
|
else if (tableName.Contains("collection_runs")) PrimaryKeyColumn = "run_id";
|
||||||
|
else if (tableName.Contains("workspace_meta")) PrimaryKeyColumn = "key";
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataSql = $"SELECT * FROM {tableName} LIMIT 100;";
|
||||||
|
using var cmd = new NpgsqlCommand(dataSql, (NpgsqlConnection)conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
|
for (int i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
ColumnNames.Add(reader.GetName(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object>();
|
||||||
|
for (int i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
var val = reader.GetValue(i);
|
||||||
|
row[reader.GetName(i)] = val == DBNull.Value ? "null" : val;
|
||||||
|
}
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load table data for {Table}", tableName);
|
||||||
|
ErrorMessage = $"테이블 데이터 조회 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-body">
|
<div class="page-body">
|
||||||
|
@if (TempData["SuccessMessage"] != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success" role="alert" style="background-color: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.3); color: #81c784; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||||||
|
@TempData["SuccessMessage"]
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (TempData["ErrorMessage"] != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" role="alert" style="background-color: rgba(244, 67, 54, 0.15); border: 1px solid rgba(244, 67, 54, 0.3); color: #ff7675; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||||||
|
@TempData["ErrorMessage"]
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="row row-deck row-cards">
|
<div class="row row-deck row-cards">
|
||||||
<!-- 예약된 작업 -->
|
<!-- 예약된 작업 -->
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -56,7 +68,12 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="javascript:void(0)" class="btn btn-sm btn-link">수정</a>
|
<form method="post" action="/Admin/Operations?handler=TriggerJob" class="d-inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="jobId" value="@job.JobId" />
|
||||||
|
<button type="submit" class="btn btn-sm btn-success text-white">즉시 실행</button>
|
||||||
|
</form>
|
||||||
|
<a href="javascript:void(0)" class="btn btn-sm btn-link ms-2">수정</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.Storage;
|
using Hangfire.Storage;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using QuantEngine.Web.Services;
|
using QuantEngine.Web.Services;
|
||||||
|
|
||||||
@@ -32,6 +33,29 @@ public class IndexModel : PageModel
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostTriggerJobAsync(string jobId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(jobId))
|
||||||
|
{
|
||||||
|
TempData["ErrorMessage"] = "올바르지 않은 작업 ID입니다.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RecurringJob.TriggerJob(jobId);
|
||||||
|
TempData["SuccessMessage"] = $"작업 '{DescribeJobId(jobId)}'이(가) 즉시 실행 큐에 등록되었습니다.";
|
||||||
|
_logger.LogInformation("Manually triggered Hangfire recurring job: {JobId}", jobId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to trigger Hangfire job: {JobId}", jobId);
|
||||||
|
TempData["ErrorMessage"] = $"작업 실행 실패: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
private void LoadOperationsData()
|
private void LoadOperationsData()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -51,6 +75,7 @@ public class IndexModel : PageModel
|
|||||||
var recurringJobs = connection.GetRecurringJobs();
|
var recurringJobs = connection.GetRecurringJobs();
|
||||||
ScheduledJobs = recurringJobs
|
ScheduledJobs = recurringJobs
|
||||||
.Select(j => new ScheduledJobInfo(
|
.Select(j => new ScheduledJobInfo(
|
||||||
|
j.Id,
|
||||||
DescribeJobId(j.Id),
|
DescribeJobId(j.Id),
|
||||||
DescribeCron(j.Cron),
|
DescribeCron(j.Cron),
|
||||||
j.NextExecution,
|
j.NextExecution,
|
||||||
@@ -62,18 +87,26 @@ public class IndexModel : PageModel
|
|||||||
InactiveJobsCount = TotalJobsCount - ActiveJobsCount;
|
InactiveJobsCount = TotalJobsCount - ActiveJobsCount;
|
||||||
|
|
||||||
var succeeded = monitoringApi.SucceededJobs(0, 10)
|
var succeeded = monitoringApi.SucceededJobs(0, 10)
|
||||||
.Select(kv => new JobExecutionInfo(
|
.Select(kv => {
|
||||||
kv.Value.Job?.Method.Name ?? kv.Key,
|
var succeededAt = kv.Value.SucceededAt ?? DateTime.UtcNow;
|
||||||
kv.Value.SucceededAt ?? DateTime.UtcNow,
|
var durationMs = kv.Value.TotalDuration ?? 0;
|
||||||
kv.Value.SucceededAt,
|
var startedAt = succeededAt.AddMilliseconds(-durationMs);
|
||||||
true));
|
return new JobExecutionInfo(
|
||||||
|
kv.Value.Job?.Method.Name ?? kv.Key,
|
||||||
|
startedAt,
|
||||||
|
succeededAt,
|
||||||
|
true);
|
||||||
|
});
|
||||||
|
|
||||||
var failed = monitoringApi.FailedJobs(0, 10)
|
var failed = monitoringApi.FailedJobs(0, 10)
|
||||||
.Select(kv => new JobExecutionInfo(
|
.Select(kv => {
|
||||||
kv.Value.Job?.Method.Name ?? kv.Key,
|
var failedAt = kv.Value.FailedAt ?? DateTime.UtcNow;
|
||||||
kv.Value.FailedAt ?? DateTime.UtcNow,
|
return new JobExecutionInfo(
|
||||||
kv.Value.FailedAt,
|
kv.Value.Job?.Method.Name ?? kv.Key,
|
||||||
false));
|
failedAt,
|
||||||
|
failedAt,
|
||||||
|
false);
|
||||||
|
});
|
||||||
|
|
||||||
RecentExecutions = succeeded.Concat(failed)
|
RecentExecutions = succeeded.Concat(failed)
|
||||||
.OrderByDescending(e => e.StartedAt)
|
.OrderByDescending(e => e.StartedAt)
|
||||||
@@ -108,7 +141,7 @@ public class IndexModel : PageModel
|
|||||||
private static string DescribeCron(string cron) => cron switch
|
private static string DescribeCron(string cron) => cron switch
|
||||||
{
|
{
|
||||||
"0 9 * * *" => "매일 09:00",
|
"0 9 * * *" => "매일 09:00",
|
||||||
"0 9-15 * * 1-5" => "평일 09-15시 매시",
|
"0 9,11,13,15 * * 1-5" => "평일 2시간 단위 (09:00~15:00)",
|
||||||
"0 17 * * 5" => "매주 금요일 17:00",
|
"0 17 * * 5" => "매주 금요일 17:00",
|
||||||
"0 2 1 * *" => "매월 1일 02:00",
|
"0 2 1 * *" => "매월 1일 02:00",
|
||||||
_ => cron,
|
_ => cron,
|
||||||
@@ -127,5 +160,5 @@ public class IndexModel : PageModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ScheduledJobInfo(string JobName, string Schedule, DateTime? NextRun, bool IsEnabled);
|
public record ScheduledJobInfo(string JobId, string JobName, string Schedule, DateTime? NextRun, bool IsEnabled);
|
||||||
public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess);
|
public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess);
|
||||||
|
|||||||
@@ -53,11 +53,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>@(user.CreatedAt ?? "-")</td>
|
<td>@(user.CreatedAt ?? "-")</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/Admin/Users/@user.Username/Edit" class="btn btn-sm btn-link">수정</a>
|
<a asp-page="./Edit" asp-route-username="@user.Username" class="btn btn-sm btn-link">수정</a>
|
||||||
<form method="post" style="display:inline;" onsubmit="return confirm('이 사용자를 비활성화하시겠습니까?');">
|
<form method="post" asp-page-handler="Delete" asp-route-username="@user.Username" style="display:inline;" onsubmit="return confirm('이 사용자를 비활성화하시겠습니까?');">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="handler" value="delete" />
|
|
||||||
<input type="hidden" name="username" value="@user.Username" />
|
|
||||||
<button type="submit" class="btn btn-sm btn-link text-danger">비활성화</button>
|
<button type="submit" class="btn btn-sm btn-link text-danger">비활성화</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
<span class="nav-link-title">운영 관리</span>
|
<span class="nav-link-title">운영 관리</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link @NavActive("/Admin/Database")" href="/Admin/Database">
|
||||||
|
<span class="nav-link-icon"><i class="ti ti-table"></i></span>
|
||||||
|
<span class="nav-link-title">DB 테이블 관리</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using QuantEngine.Infrastructure.Data;
|
using QuantEngine.Infrastructure.Data;
|
||||||
using QuantEngine.Infrastructure.Repositories;
|
using QuantEngine.Infrastructure.Repositories;
|
||||||
using QuantEngine.Infrastructure.Services;
|
using QuantEngine.Infrastructure.Services;
|
||||||
@@ -22,6 +23,22 @@ try
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
|
// Data Protection: without this, ASP.NET Core derives its key-ring
|
||||||
|
// discriminator from the app's physical content root path. Every
|
||||||
|
// deployment lands in a brand-new directory
|
||||||
|
// (~/deployments/quantengine_{tag}_{hash}/), so the discriminator
|
||||||
|
// changed on every single deploy and every previously-issued auth
|
||||||
|
// cookie became undecryptable -- forcing all users to log in again
|
||||||
|
// after each release. SetApplicationName pins a stable discriminator;
|
||||||
|
// PersistKeysToFileSystem points at a location outside the versioned
|
||||||
|
// deployment folders so the actual key material also survives restarts.
|
||||||
|
var dataProtectionKeysPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"quantengine-keys");
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.SetApplicationName("QuantEngine")
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
|
||||||
|
|
||||||
// Authentication & Authorization
|
// Authentication & Authorization
|
||||||
builder.Services.AddAuthentication(opts =>
|
builder.Services.AddAuthentication(opts =>
|
||||||
{
|
{
|
||||||
@@ -79,12 +96,23 @@ try
|
|||||||
// Repository Services
|
// Repository Services
|
||||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||||
|
builder.Services.AddScoped<INormalizedLearningStore, NormalizedLearningStore>();
|
||||||
|
builder.Services.AddScoped<ILearningDatasetReader, LearningDatasetReader>();
|
||||||
|
builder.Services.AddScoped<DecisionLearningService>();
|
||||||
|
builder.Services.AddScoped<LearningDatasetService>();
|
||||||
|
builder.Services.AddSingleton<GatherTradingDataParser>();
|
||||||
|
builder.Services.AddScoped<JsonSeedIngestionService>();
|
||||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||||
builder.Services.AddScoped<HistoryIngestionService>();
|
builder.Services.AddScoped<HistoryIngestionService>();
|
||||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||||
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
||||||
|
|
||||||
|
// Collection Pipeline Services
|
||||||
|
builder.Services.AddScoped<SourcePriorityResolver>();
|
||||||
|
builder.Services.AddScoped<PriceDataNormalizer>();
|
||||||
|
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||||
|
|
||||||
// Hangfire Background Jobs
|
// Hangfire Background Jobs
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,25 +18,10 @@
|
|||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Exclude client project files from server build to avoid duplicate compilations -->
|
|
||||||
<!-- BUT preserve Client\wwwroot for static web assets -->
|
|
||||||
<Compile Remove="Client\**" />
|
|
||||||
<EmbeddedResource Remove="Client\**" />
|
|
||||||
<None Remove="Client\**" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Only remove non-wwwroot Client content -->
|
|
||||||
<Content Remove="Client\**" />
|
|
||||||
<Content Include="Client\wwwroot\**" CopyToPublishDirectory="Never" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ public interface IIpLockoutService
|
|||||||
|
|
||||||
public class IpLockoutService : IIpLockoutService
|
public class IpLockoutService : IIpLockoutService
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, (int Attempts, DateTime LockedUntil)> _attemptLog = [];
|
private readonly Dictionary<string, (int Attempts, int LockoutCount, DateTime LockedUntil)> _attemptLog = [];
|
||||||
private const int MaxFailedAttempts = 3;
|
private const int MaxFailedAttempts = 3;
|
||||||
private const int LockoutDurationMinutes = 15;
|
private const int LockoutDurationMinutes = 15;
|
||||||
|
private const int MaxLockoutDurationMinutes = 1440; // 24 hours cap
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
public bool IsLockedOut(string ipAddress)
|
public bool IsLockedOut(string ipAddress)
|
||||||
@@ -23,7 +24,11 @@ public class IpLockoutService : IIpLockoutService
|
|||||||
if (DateTime.UtcNow < record.LockedUntil)
|
if (DateTime.UtcNow < record.LockedUntil)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
_attemptLog.Remove(ipAddress);
|
// Reset failed attempts but keep the LockoutCount to calculate progressive delay on next failure
|
||||||
|
if (record.Attempts >= MaxFailedAttempts)
|
||||||
|
{
|
||||||
|
_attemptLog[ipAddress] = (0, record.LockoutCount, DateTime.MinValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -39,14 +44,19 @@ public class IpLockoutService : IIpLockoutService
|
|||||||
record.Attempts++;
|
record.Attempts++;
|
||||||
if (record.Attempts >= MaxFailedAttempts)
|
if (record.Attempts >= MaxFailedAttempts)
|
||||||
{
|
{
|
||||||
record.LockedUntil = DateTime.UtcNow.AddMinutes(LockoutDurationMinutes);
|
// Calculate exponential progressive lockout duration: 15 * 2^LockoutCount
|
||||||
|
var durationMinutes = LockoutDurationMinutes * Math.Pow(2, record.LockoutCount);
|
||||||
|
durationMinutes = Math.Min(durationMinutes, MaxLockoutDurationMinutes);
|
||||||
|
|
||||||
|
record.LockedUntil = DateTime.UtcNow.AddMinutes(durationMinutes);
|
||||||
|
record.LockoutCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_attemptLog[ipAddress] = record;
|
_attemptLog[ipAddress] = record;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_attemptLog[ipAddress] = (1, DateTime.UtcNow);
|
_attemptLog[ipAddress] = (1, 0, DateTime.MinValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ using Hangfire.PostgreSql;
|
|||||||
using Hangfire.MemoryStorage;
|
using Hangfire.MemoryStorage;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
|
using QuantEngine.Application.Interfaces;
|
||||||
using QuantEngine.Infrastructure.Data;
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace QuantEngine.Web.Services;
|
namespace QuantEngine.Web.Services;
|
||||||
|
|
||||||
@@ -17,15 +19,21 @@ public class SchedulerService
|
|||||||
private readonly ILogger<SchedulerService> _logger;
|
private readonly ILogger<SchedulerService> _logger;
|
||||||
private readonly IBackgroundJobClient _jobClient;
|
private readonly IBackgroundJobClient _jobClient;
|
||||||
private readonly IRecurringJobManager _recurringJobManager;
|
private readonly IRecurringJobManager _recurringJobManager;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public SchedulerService(
|
public SchedulerService(
|
||||||
ILogger<SchedulerService> logger,
|
ILogger<SchedulerService> logger,
|
||||||
IBackgroundJobClient jobClient,
|
IBackgroundJobClient jobClient,
|
||||||
IRecurringJobManager recurringJobManager)
|
IRecurringJobManager recurringJobManager,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_jobClient = jobClient;
|
_jobClient = jobClient;
|
||||||
_recurringJobManager = recurringJobManager;
|
_recurringJobManager = recurringJobManager;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,11 +53,11 @@ public class SchedulerService
|
|||||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }
|
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hourly price update (during market hours 9 AM - 4 PM)
|
// Hourly price update (during market hours 9 AM - 4 PM, every 2 hours)
|
||||||
_recurringJobManager.AddOrUpdate(
|
_recurringJobManager.AddOrUpdate(
|
||||||
"hourly-price-update",
|
"hourly-price-update",
|
||||||
() => UpdatePricesAsync(),
|
() => UpdatePricesAsync(),
|
||||||
"0 9-15 * * 1-5", // Every hour, 9 AM to 3 PM, Mon-Fri
|
"0 9,11,13,15 * * 1-5", // 9:00, 11:00, 13:00, 15:00 on Mon-Fri
|
||||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }
|
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -89,14 +97,22 @@ public class SchedulerService
|
|||||||
// List of tickers to collect
|
// List of tickers to collect
|
||||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
|
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
|
||||||
|
|
||||||
foreach (var ticker in tickers)
|
// Create scope for scoped services
|
||||||
{
|
using var scope = _scopeFactory.CreateScope();
|
||||||
// Simulate data collection
|
var orchestrator = scope.ServiceProvider.GetRequiredService<ICollectionOrchestrator>();
|
||||||
await Task.Delay(100);
|
|
||||||
_logger.LogInformation("Collected data for ticker: {Ticker}", ticker);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Daily data collection completed at {Time}", DateTime.Now);
|
// Build runId with timestamp
|
||||||
|
var runId = $"daily-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||||
|
|
||||||
|
// Read account mode from configuration (default to "mock")
|
||||||
|
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||||
|
|
||||||
|
// Execute collection
|
||||||
|
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers.ToList());
|
||||||
|
|
||||||
|
// Log completion
|
||||||
|
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
|
||||||
|
runId, result.SuccessCount, result.ErrorCount);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -312,8 +328,7 @@ public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
|||||||
{
|
{
|
||||||
public bool Authorize(DashboardContext context)
|
public bool Authorize(DashboardContext context)
|
||||||
{
|
{
|
||||||
// TODO: Implement proper authorization check
|
var httpContext = context.GetHttpContext();
|
||||||
// For now, allow all in development
|
return httpContext.User.Identity?.IsAuthenticated == true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
2026-07-11 17:02:14.217 +09:00 [FTL] Application terminated unexpectedly
|
|
||||||
System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.)
|
|
||||||
---> System.InvalidOperationException: Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.
|
|
||||||
---> System.InvalidOperationException: Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateArgumentCallSites(ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain, ParameterInfo[] parameters, Boolean throwIfCallSiteNotFound)
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain)
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateExact(ServiceDescriptor descriptor, ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain, Int32 slot)
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(ServiceDescriptor serviceDescriptor, CallSiteChain callSiteChain)
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor)
|
|
||||||
--- End of inner exception stack trace ---
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor)
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
|
|
||||||
--- End of inner exception stack trace ---
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
|
|
||||||
at Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(IServiceCollection services, ServiceProviderOptions options)
|
|
||||||
at Microsoft.Extensions.Hosting.HostApplicationBuilder.Build()
|
|
||||||
at Microsoft.AspNetCore.Builder.WebApplicationBuilder.Build()
|
|
||||||
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 99
|
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
2026-07-11 18:41:46.431 +09:00 [INF] Registered 10 endpoints in 164 milliseconds.
|
||||||
|
2026-07-11 18:41:46.473 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:41:50.463 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:41:50.464 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:41:50.476 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:41:50.498 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:42:27.125 +09:00 [INF] Registered 10 endpoints in 178 milliseconds.
|
||||||
|
2026-07-11 18:42:27.164 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:42:31.234 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:42:31.234 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:42:31.240 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:42:31.281 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:42:35.322 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-11 18:42:35.536 +09:00 [ERR] Hosting failed to start
|
||||||
|
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||||
|
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||||
|
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||||
|
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||||
|
2026-07-11 18:42:35.543 +09:00 [FTL] Application terminated unexpectedly
|
||||||
|
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||||
|
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||||
|
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||||
|
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
|
||||||
|
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 172
|
||||||
|
2026-07-11 18:47:26.979 +09:00 [INF] Registered 10 endpoints in 173 milliseconds.
|
||||||
|
2026-07-11 18:47:27.018 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:47:31.144 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:47:31.144 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:47:31.149 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:47:31.177 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:47:58.996 +09:00 [INF] Registered 10 endpoints in 163 milliseconds.
|
||||||
|
2026-07-11 18:47:59.034 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:48:03.135 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:48:03.136 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:48:03.142 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:48:03.166 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:48:07.181 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-11 18:48:07.453 +09:00 [ERR] Hosting failed to start
|
||||||
|
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||||
|
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||||
|
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||||
|
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||||
|
2026-07-11 18:48:07.460 +09:00 [FTL] Application terminated unexpectedly
|
||||||
|
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||||
|
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||||
|
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||||
|
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
|
||||||
|
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 172
|
||||||
|
2026-07-11 18:49:42.328 +09:00 [INF] Registered 10 endpoints in 160 milliseconds.
|
||||||
|
2026-07-11 18:49:42.368 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:49:46.632 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:49:46.632 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:49:46.635 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:49:46.655 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:49:50.782 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-11 18:49:50.940 +09:00 [INF] Now listening on: http://localhost:5265
|
||||||
|
2026-07-11 18:49:50.946 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||||
|
2026-07-11 18:49:50.946 +09:00 [INF] Using the following options for Hangfire Server:
|
||||||
|
Worker count: 32
|
||||||
|
Listening queues: 'default'
|
||||||
|
Shutdown timeout: 00:00:15
|
||||||
|
Schedule polling interval: 00:00:15
|
||||||
|
2026-07-11 18:49:50.966 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-07-11 18:49:50.966 +09:00 [INF] Hosting environment: Development
|
||||||
|
2026-07-11 18:49:50.966 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||||
|
2026-07-11 18:49:51.046 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 successfully announced in 77.2082 ms
|
||||||
|
2026-07-11 18:49:51.050 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||||
|
2026-07-11 18:49:51.076 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 all the dispatchers started
|
||||||
|
2026-07-11 18:50:18.938 +09:00 [INF] Registered 10 endpoints in 161 milliseconds.
|
||||||
|
2026-07-11 18:50:18.977 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:50:23.249 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:50:23.250 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:50:23.256 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:50:23.283 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:50:27.388 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-11 18:50:27.569 +09:00 [INF] Now listening on: http://localhost:5265
|
||||||
|
2026-07-11 18:50:27.577 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||||
|
2026-07-11 18:50:27.577 +09:00 [INF] Using the following options for Hangfire Server:
|
||||||
|
Worker count: 32
|
||||||
|
Listening queues: 'default'
|
||||||
|
Shutdown timeout: 00:00:15
|
||||||
|
Schedule polling interval: 00:00:15
|
||||||
|
2026-07-11 18:50:27.589 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-07-11 18:50:27.590 +09:00 [INF] Hosting environment: Development
|
||||||
|
2026-07-11 18:50:27.590 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||||
|
2026-07-11 18:50:27.646 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c successfully announced in 54.5404 ms
|
||||||
|
2026-07-11 18:50:27.648 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||||
|
2026-07-11 18:50:27.667 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c all the dispatchers started
|
||||||
|
2026-07-11 18:50:40.967 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 18:50:40.972 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||||
|
2026-07-11 18:50:41.007 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 18:50:41.023 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 18:50:41.030 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 18:50:41.031 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 18:50:41.033 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 18:50:41.033 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 18:50:41.058 +09:00 [INF] Executed page /Account/Login in 32.7802ms
|
||||||
|
2026-07-11 18:50:41.059 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 18:50:41.060 +09:00 [INF] HTTP GET /Account/Login responded 200 in 88.7566 ms
|
||||||
|
2026-07-11 18:50:41.062 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 94.7893ms
|
||||||
|
2026-07-11 18:50:41.174 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-11 18:50:41.178 +09:00 [ERR] HTTP GET /Admin/Dashboard responded 500 in 4.0554 ms
|
||||||
|
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
2026-07-11 18:50:41.181 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||||
|
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||||
|
2026-07-11 18:50:41.185 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 500 null text/plain; charset=utf-8 12.0749ms
|
||||||
|
2026-07-11 18:50:41.298 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 18:50:41.303 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 18:50:41.303 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 18:50:41.304 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 18:50:41.304 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 18:50:41.304 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 18:50:41.304 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 18:50:41.310 +09:00 [INF] Executed page /Account/Login in 6.8704ms
|
||||||
|
2026-07-11 18:50:41.310 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 18:50:41.310 +09:00 [INF] HTTP GET /Account/Login responded 200 in 12.5730 ms
|
||||||
|
2026-07-11 18:50:41.311 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 12.9475ms
|
||||||
|
2026-07-11 18:50:45.183 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-11 18:50:45.185 +09:00 [ERR] HTTP GET /Admin/Dashboard responded 500 in 1.8220 ms
|
||||||
|
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
2026-07-11 18:50:45.186 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||||
|
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||||
|
2026-07-11 18:50:45.186 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 500 null text/plain; charset=utf-8 2.8989ms
|
||||||
|
2026-07-11 18:51:53.325 +09:00 [INF] Registered 10 endpoints in 159 milliseconds.
|
||||||
|
2026-07-11 18:51:53.369 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-11 18:51:57.672 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:51:57.672 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-11 18:51:57.699 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-11 18:51:57.728 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-11 18:52:01.841 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-11 18:52:02.012 +09:00 [INF] Now listening on: http://localhost:5265
|
||||||
|
2026-07-11 18:52:02.023 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||||
|
2026-07-11 18:52:02.024 +09:00 [INF] Using the following options for Hangfire Server:
|
||||||
|
Worker count: 32
|
||||||
|
Listening queues: 'default'
|
||||||
|
Shutdown timeout: 00:00:15
|
||||||
|
Schedule polling interval: 00:00:15
|
||||||
|
2026-07-11 18:52:02.041 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-07-11 18:52:02.041 +09:00 [INF] Hosting environment: Development
|
||||||
|
2026-07-11 18:52:02.041 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||||
|
2026-07-11 18:52:02.106 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a successfully announced in 61.5885 ms
|
||||||
|
2026-07-11 18:52:02.108 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||||
|
2026-07-11 18:52:02.115 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a all the dispatchers started
|
||||||
|
2026-07-11 18:52:13.775 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 18:52:13.780 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||||
|
2026-07-11 18:52:13.833 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 18:52:13.849 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 18:52:13.855 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 18:52:13.856 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 18:52:13.858 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 18:52:13.859 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 18:52:13.883 +09:00 [INF] Executed page /Account/Login in 32.0627ms
|
||||||
|
2026-07-11 18:52:13.884 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 18:52:13.885 +09:00 [INF] HTTP GET /Account/Login responded 200 in 106.3802 ms
|
||||||
|
2026-07-11 18:52:13.887 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 112.9483ms
|
||||||
|
2026-07-11 18:52:14.003 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-11 18:52:14.011 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.013 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.013 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 10.4176 ms
|
||||||
|
2026-07-11 18:52:14.014 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 11.3635ms
|
||||||
|
2026-07-11 18:52:14.120 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-11 18:52:14.124 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.124 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.125 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 4.4516 ms
|
||||||
|
2026-07-11 18:52:14.125 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 4.7369ms
|
||||||
|
2026-07-11 18:52:14.308 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-11 18:52:14.309 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.310 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.310 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 1.4155 ms
|
||||||
|
2026-07-11 18:52:14.310 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 1.7032ms
|
||||||
|
2026-07-11 18:52:14.410 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Users - null null
|
||||||
|
2026-07-11 18:52:14.411 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.411 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.411 +09:00 [INF] HTTP GET /Admin/Users responded 302 in 0.4739 ms
|
||||||
|
2026-07-11 18:52:14.411 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Users - 302 0 null 0.7314ms
|
||||||
|
2026-07-11 18:52:14.523 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Collection - null null
|
||||||
|
2026-07-11 18:52:14.523 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.523 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.524 +09:00 [INF] HTTP GET /Admin/Collection responded 302 in 0.6342 ms
|
||||||
|
2026-07-11 18:52:14.524 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Collection - 302 0 null 1.6071ms
|
||||||
|
2026-07-11 18:52:14.642 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - null null
|
||||||
|
2026-07-11 18:52:14.643 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.643 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.643 +09:00 [INF] HTTP GET /Admin/Monitoring responded 302 in 0.4305 ms
|
||||||
|
2026-07-11 18:52:14.643 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - 302 0 null 0.677ms
|
||||||
|
2026-07-11 18:52:14.741 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Operations - null null
|
||||||
|
2026-07-11 18:52:14.741 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||||
|
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||||
|
2026-07-11 18:52:14.742 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||||
|
2026-07-11 18:52:14.742 +09:00 [INF] HTTP GET /Admin/Operations responded 302 in 0.4306 ms
|
||||||
|
2026-07-11 18:52:14.742 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Operations - 302 0 null 0.6763ms
|
||||||
|
2026-07-11 21:04:06.174 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-11 21:04:06.177 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:04:06.177 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:04:06.178 +09:00 [INF] HTTP GET /login responded 302 in 2.1715 ms
|
||||||
|
2026-07-11 21:04:06.178 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 3.7332ms
|
||||||
|
2026-07-11 21:04:06.191 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 21:04:06.194 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:04:06.195 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 21:04:06.198 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 21:04:06.199 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 21:04:06.199 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 21:04:06.200 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 21:04:06.212 +09:00 [INF] Executed page /Account/Login in 16.6942ms
|
||||||
|
2026-07-11 21:04:06.212 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:04:06.212 +09:00 [INF] HTTP GET /Account/Login responded 200 in 21.4926 ms
|
||||||
|
2026-07-11 21:04:06.213 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 22.1426ms
|
||||||
|
2026-07-11 21:04:06.222 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-11 21:04:06.223 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:04:06.223 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:04:06.223 +09:00 [INF] HTTP GET /login responded 302 in 0.5991 ms
|
||||||
|
2026-07-11 21:04:06.223 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 1.2095ms
|
||||||
|
2026-07-11 21:04:06.226 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 21:04:06.227 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:04:06.227 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 21:04:06.231 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 21:04:06.232 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 21:04:06.232 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 21:04:06.233 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 21:04:06.238 +09:00 [INF] Executed page /Account/Login in 10.6054ms
|
||||||
|
2026-07-11 21:04:06.238 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:04:06.238 +09:00 [INF] HTTP GET /Account/Login responded 200 in 11.7060 ms
|
||||||
|
2026-07-11 21:04:06.238 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 12.2449ms
|
||||||
|
2026-07-11 21:05:05.597 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-11 21:05:05.597 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:05:05.598 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:05:05.598 +09:00 [INF] HTTP GET /login responded 302 in 0.4316 ms
|
||||||
|
2026-07-11 21:05:05.598 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.8539ms
|
||||||
|
2026-07-11 21:05:05.606 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 21:05:05.606 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:05:05.606 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 21:05:05.606 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 21:05:05.606 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 21:05:05.607 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 21:05:05.607 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 21:05:05.607 +09:00 [INF] Executed page /Account/Login in 1.1238ms
|
||||||
|
2026-07-11 21:05:05.607 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:05:05.607 +09:00 [INF] HTTP GET /Account/Login responded 200 in 1.6523 ms
|
||||||
|
2026-07-11 21:05:05.607 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.9459ms
|
||||||
|
2026-07-11 21:05:05.611 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-11 21:05:05.612 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:05:05.612 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-11 21:05:05.612 +09:00 [INF] HTTP GET /login responded 302 in 0.3471 ms
|
||||||
|
2026-07-11 21:05:05.612 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.603ms
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-11 21:05:05.616 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-11 21:05:05.617 +09:00 [INF] Executed page /Account/Login in 0.5619ms
|
||||||
|
2026-07-11 21:05:05.617 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-11 21:05:05.617 +09:00 [INF] HTTP GET /Account/Login responded 200 in 0.9286 ms
|
||||||
|
2026-07-11 21:05:05.617 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.1671ms
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
2026-07-12 01:49:59.393 +09:00 [INF] Registered 10 endpoints in 432 milliseconds.
|
||||||
|
2026-07-12 01:49:59.756 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-12 01:50:02.068 +09:00 [INF] ✅ Database migration completed successfully
|
||||||
|
2026-07-12 01:50:05.349 +09:00 [INF] Database migration and initialization successful
|
||||||
|
2026-07-12 01:50:05.388 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-12 01:50:06.028 +09:00 [INF] Start installing Hangfire SQL objects...
|
||||||
|
2026-07-12 01:50:10.702 +09:00 [INF] Hangfire SQL objects installed.
|
||||||
|
2026-07-12 01:50:10.728 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-12 01:50:10.877 +09:00 [ERR] Hosting failed to start
|
||||||
|
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||||
|
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||||
|
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||||
|
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||||
|
2026-07-12 01:50:10.905 +09:00 [FTL] Application terminated unexpectedly
|
||||||
|
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||||
|
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||||
|
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||||
|
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||||
|
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||||
|
--- End of stack trace from previous location ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
--- End of inner exception stack trace ---
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||||
|
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||||
|
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||||
|
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
|
||||||
|
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 178
|
||||||
|
2026-07-12 01:50:55.147 +09:00 [INF] Registered 10 endpoints in 161 milliseconds.
|
||||||
|
2026-07-12 01:50:55.188 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-12 01:50:57.488 +09:00 [INF] ✅ Database migration completed successfully
|
||||||
|
2026-07-12 01:51:00.589 +09:00 [INF] Database migration and initialization successful
|
||||||
|
2026-07-12 01:51:00.610 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-12 01:51:00.632 +09:00 [INF] Start installing Hangfire SQL objects...
|
||||||
|
2026-07-12 01:51:05.254 +09:00 [INF] Hangfire SQL objects installed.
|
||||||
|
2026-07-12 01:51:05.261 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||||
|
2026-07-12 01:51:05.409 +09:00 [INF] Now listening on: http://localhost:5265
|
||||||
|
2026-07-12 01:51:05.417 +09:00 [INF] Starting Hangfire Server using job storage: 'PostgreSQL Server: Host: 127.0.0.1, DB: quantenginedb, Schema: hangfire'
|
||||||
|
2026-07-12 01:51:05.417 +09:00 [INF] Using the following options for PostgreSQL job storage:
|
||||||
|
2026-07-12 01:51:05.418 +09:00 [INF] Queue poll interval: 00:00:15.
|
||||||
|
2026-07-12 01:51:05.418 +09:00 [INF] Invisibility timeout: 00:30:00.
|
||||||
|
2026-07-12 01:51:05.418 +09:00 [INF] Use sliding invisibility timeout: False.
|
||||||
|
2026-07-12 01:51:05.418 +09:00 [INF] Using the following options for Hangfire Server:
|
||||||
|
Worker count: 32
|
||||||
|
Listening queues: 'default'
|
||||||
|
Shutdown timeout: 00:00:15
|
||||||
|
Schedule polling interval: 00:00:15
|
||||||
|
2026-07-12 01:51:05.434 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-07-12 01:51:05.434 +09:00 [INF] Hosting environment: Development
|
||||||
|
2026-07-12 01:51:05.434 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||||
|
2026-07-12 01:51:05.741 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 successfully announced in 304.3044 ms
|
||||||
|
2026-07-12 01:51:05.744 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||||
|
2026-07-12 01:51:05.762 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 all the dispatchers started
|
||||||
|
2026-07-12 01:51:16.896 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 01:51:16.900 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||||
|
2026-07-12 01:51:16.932 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:16.958 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:51:16.970 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:16.972 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 01:51:16.977 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:16.978 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:51:17.024 +09:00 [INF] Executed page /Account/Login in 63.0425ms
|
||||||
|
2026-07-12 01:51:17.025 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:17.026 +09:00 [INF] HTTP GET /Account/Login responded 200 in 126.7878 ms
|
||||||
|
2026-07-12 01:51:17.028 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 132.7688ms
|
||||||
|
2026-07-12 01:51:42.662 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 01:51:42.685 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:42.685 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:51:42.687 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:42.687 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 01:51:42.687 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:42.687 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:51:42.694 +09:00 [INF] Executed page /Account/Login in 9.2179ms
|
||||||
|
2026-07-12 01:51:42.695 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:42.695 +09:00 [INF] HTTP GET /Account/Login responded 200 in 32.3239 ms
|
||||||
|
2026-07-12 01:51:42.695 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 34.0934ms
|
||||||
|
2026-07-12 01:51:43.344 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
|
||||||
|
2026-07-12 01:51:43.352 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:43.353 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:51:43.402 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:44.200 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
|
||||||
|
2026-07-12 01:51:44.201 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
|
||||||
|
2026-07-12 01:51:44.205 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
|
||||||
|
2026-07-12 01:51:44.210 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
|
||||||
|
2026-07-12 01:51:44.213 +09:00 [INF] Executed page /Account/Login in 859.92ms
|
||||||
|
2026-07-12 01:51:44.214 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:44.216 +09:00 [INF] HTTP POST /Account/Login responded 302 in 871.2169 ms
|
||||||
|
2026-07-12 01:51:44.218 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 873.6838ms
|
||||||
|
2026-07-12 01:51:44.222 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-12 01:51:44.232 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
|
||||||
|
2026-07-12 01:51:44.252 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
|
||||||
|
2026-07-12 01:51:44.253 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:45.151 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:51:45.151 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:45.152 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:51:45.229 +09:00 [INF] Executed page /Admin/Dashboard/Index in 976.4261ms
|
||||||
|
2026-07-12 01:51:45.229 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
|
||||||
|
2026-07-12 01:51:45.230 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 1007.5596 ms
|
||||||
|
2026-07-12 01:51:45.230 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 1008.1707ms
|
||||||
|
2026-07-12 01:51:45.238 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
|
||||||
|
2026-07-12 01:51:45.255 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
|
||||||
|
2026-07-12 01:51:45.256 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 17.9079 ms
|
||||||
|
2026-07-12 01:51:45.256 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 18.4204ms
|
||||||
|
2026-07-12 01:51:45.933 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Operations - null null
|
||||||
|
2026-07-12 01:51:45.937 +09:00 [INF] Executing endpoint '/Admin/Operations/Index'
|
||||||
|
2026-07-12 01:51:45.951 +09:00 [INF] Route matched with {page = "/Admin/Operations/Index"}. Executing page /Admin/Operations/Index
|
||||||
|
2026-07-12 01:51:45.952 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Operations.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:48.823 +09:00 [INF] Operations data loaded from Hangfire (4 recurring jobs, 2 servers)
|
||||||
|
2026-07-12 01:51:48.824 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:51:48.824 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:48.824 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:51:48.846 +09:00 [INF] Executed page /Admin/Operations/Index in 2894.9938ms
|
||||||
|
2026-07-12 01:51:48.847 +09:00 [INF] Executed endpoint '/Admin/Operations/Index'
|
||||||
|
2026-07-12 01:51:48.847 +09:00 [INF] HTTP GET /Admin/Operations responded 200 in 2913.4775 ms
|
||||||
|
2026-07-12 01:51:48.847 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Operations - 200 null text/html; charset=utf-8 2914.1336ms
|
||||||
|
2026-07-12 01:51:49.732 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 01:51:49.732 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:49.732 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:51:49.732 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:49.733 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 01:51:49.733 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:49.733 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:51:49.735 +09:00 [INF] Executed page /Account/Login in 2.6158ms
|
||||||
|
2026-07-12 01:51:49.735 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:49.735 +09:00 [INF] HTTP GET /Account/Login responded 200 in 3.4623 ms
|
||||||
|
2026-07-12 01:51:49.735 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 3.8328ms
|
||||||
|
2026-07-12 01:51:50.371 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
|
||||||
|
2026-07-12 01:51:50.371 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:50.371 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:51:50.373 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:50.857 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
|
||||||
|
2026-07-12 01:51:50.857 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
|
||||||
|
2026-07-12 01:51:50.857 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
|
||||||
|
2026-07-12 01:51:50.858 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
|
||||||
|
2026-07-12 01:51:50.858 +09:00 [INF] Executed page /Account/Login in 486.6264ms
|
||||||
|
2026-07-12 01:51:50.858 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:51:50.858 +09:00 [INF] HTTP POST /Account/Login responded 302 in 487.2066 ms
|
||||||
|
2026-07-12 01:51:50.858 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 487.636ms
|
||||||
|
2026-07-12 01:51:50.862 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-12 01:51:50.865 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
|
||||||
|
2026-07-12 01:51:50.865 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
|
||||||
|
2026-07-12 01:51:50.865 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:51.755 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:51:51.755 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:51:51.755 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:51:51.757 +09:00 [INF] Executed page /Admin/Dashboard/Index in 891.4622ms
|
||||||
|
2026-07-12 01:51:51.757 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
|
||||||
|
2026-07-12 01:51:51.757 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 894.9356 ms
|
||||||
|
2026-07-12 01:51:51.757 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 895.2421ms
|
||||||
|
2026-07-12 01:51:51.766 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
|
||||||
|
2026-07-12 01:51:51.779 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
|
||||||
|
2026-07-12 01:51:51.779 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 12.9029 ms
|
||||||
|
2026-07-12 01:51:51.780 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 13.3629ms
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:52:45.383 +09:00 [INF] Executed page /Account/Login in 0.5097ms
|
||||||
|
2026-07-12 01:52:45.384 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:52:45.384 +09:00 [INF] HTTP GET /Account/Login responded 200 in 0.8798 ms
|
||||||
|
2026-07-12 01:52:45.384 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.0536ms
|
||||||
|
2026-07-12 01:52:46.018 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
|
||||||
|
2026-07-12 01:52:46.018 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:52:46.018 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:52:46.019 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] Executed page /Account/Login in 456.9281ms
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] HTTP POST /Account/Login responded 302 in 457.3632 ms
|
||||||
|
2026-07-12 01:52:46.475 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 457.5915ms
|
||||||
|
2026-07-12 01:52:46.478 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||||
|
2026-07-12 01:52:46.479 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
|
||||||
|
2026-07-12 01:52:46.479 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
|
||||||
|
2026-07-12 01:52:46.479 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:47.363 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:52:47.363 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:47.363 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:52:47.366 +09:00 [INF] Executed page /Admin/Dashboard/Index in 886.9837ms
|
||||||
|
2026-07-12 01:52:47.366 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
|
||||||
|
2026-07-12 01:52:47.366 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 888.0406 ms
|
||||||
|
2026-07-12 01:52:47.367 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 888.4149ms
|
||||||
|
2026-07-12 01:52:47.378 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
|
||||||
|
2026-07-12 01:52:47.380 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
|
||||||
|
2026-07-12 01:52:47.380 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 2.4015 ms
|
||||||
|
2026-07-12 01:52:47.380 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 2.7681ms
|
||||||
|
2026-07-12 01:52:48.337 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Collection - null null
|
||||||
|
2026-07-12 01:52:48.338 +09:00 [INF] Executing endpoint '/Admin/Collection/Index'
|
||||||
|
2026-07-12 01:52:48.355 +09:00 [INF] Route matched with {page = "/Admin/Collection/Index"}. Executing page /Admin/Collection/Index
|
||||||
|
2026-07-12 01:52:48.355 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Collection.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:48.581 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:52:48.581 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:48.581 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:52:48.591 +09:00 [INF] Executed page /Admin/Collection/Index in 236.0372ms
|
||||||
|
2026-07-12 01:52:48.591 +09:00 [INF] Executed endpoint '/Admin/Collection/Index'
|
||||||
|
2026-07-12 01:52:48.591 +09:00 [INF] HTTP GET /Admin/Collection responded 200 in 254.0162 ms
|
||||||
|
2026-07-12 01:52:48.592 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Collection - 200 null text/html; charset=utf-8 254.527ms
|
||||||
|
2026-07-12 01:52:49.211 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - null null
|
||||||
|
2026-07-12 01:52:49.211 +09:00 [INF] Executing endpoint '/Admin/Monitoring/Index'
|
||||||
|
2026-07-12 01:52:49.217 +09:00 [INF] Route matched with {page = "/Admin/Monitoring/Index"}. Executing page /Admin/Monitoring/Index
|
||||||
|
2026-07-12 01:52:49.218 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Monitoring.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:49.440 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:52:49.440 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:49.440 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:52:49.453 +09:00 [INF] Executed page /Admin/Monitoring/Index in 235.3992ms
|
||||||
|
2026-07-12 01:52:49.453 +09:00 [INF] Executed endpoint '/Admin/Monitoring/Index'
|
||||||
|
2026-07-12 01:52:49.453 +09:00 [INF] HTTP GET /Admin/Monitoring responded 200 in 242.4802 ms
|
||||||
|
2026-07-12 01:52:49.453 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - 200 null text/html; charset=utf-8 242.724ms
|
||||||
|
2026-07-12 01:52:50.106 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Users - null null
|
||||||
|
2026-07-12 01:52:50.107 +09:00 [INF] Executing endpoint '/Admin/Users/Index'
|
||||||
|
2026-07-12 01:52:50.117 +09:00 [INF] Route matched with {page = "/Admin/Users/Index"}. Executing page /Admin/Users/Index
|
||||||
|
2026-07-12 01:52:50.118 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Users.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:50.339 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||||
|
2026-07-12 01:52:50.339 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:52:50.339 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:52:50.346 +09:00 [INF] Executed page /Admin/Users/Index in 228.3491ms
|
||||||
|
2026-07-12 01:52:50.346 +09:00 [INF] Executed endpoint '/Admin/Users/Index'
|
||||||
|
2026-07-12 01:52:50.346 +09:00 [INF] HTTP GET /Admin/Users responded 200 in 239.3317 ms
|
||||||
|
2026-07-12 01:52:50.346 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Users - 200 null text/html; charset=utf-8 239.5416ms
|
||||||
|
2026-07-12 01:56:32.591 +09:00 [INF] Registered 10 endpoints in 168 milliseconds.
|
||||||
|
2026-07-12 01:56:32.627 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-12 01:56:34.908 +09:00 [INF] ✅ Database migration completed successfully
|
||||||
|
2026-07-12 01:56:37.976 +09:00 [INF] Database migration and initialization successful
|
||||||
|
2026-07-12 01:56:38.000 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||||
|
2026-07-12 01:56:38.043 +09:00 [INF] Start installing Hangfire SQL objects...
|
||||||
|
2026-07-12 01:56:42.666 +09:00 [INF] Hangfire SQL objects installed.
|
||||||
|
2026-07-12 01:56:42.681 +09:00 [INF] Initializing Hangfire schedules...
|
||||||
|
2026-07-12 01:56:50.823 +09:00 [INF] Hangfire schedules initialized successfully
|
||||||
|
2026-07-12 01:56:50.988 +09:00 [INF] Now listening on: http://localhost:5265
|
||||||
|
2026-07-12 01:56:50.992 +09:00 [INF] Starting Hangfire Server using job storage: 'PostgreSQL Server: Host: 127.0.0.1, DB: quantenginedb, Schema: hangfire'
|
||||||
|
2026-07-12 01:56:50.992 +09:00 [INF] Using the following options for PostgreSQL job storage:
|
||||||
|
2026-07-12 01:56:50.992 +09:00 [INF] Queue poll interval: 00:00:15.
|
||||||
|
2026-07-12 01:56:50.992 +09:00 [INF] Invisibility timeout: 00:30:00.
|
||||||
|
2026-07-12 01:56:50.992 +09:00 [INF] Use sliding invisibility timeout: False.
|
||||||
|
2026-07-12 01:56:50.992 +09:00 [INF] Using the following options for Hangfire Server:
|
||||||
|
Worker count: 32
|
||||||
|
Listening queues: 'default'
|
||||||
|
Shutdown timeout: 00:00:15
|
||||||
|
Schedule polling interval: 00:00:15
|
||||||
|
2026-07-12 01:56:51.000 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-07-12 01:56:51.000 +09:00 [INF] Hosting environment: Development
|
||||||
|
2026-07-12 01:56:51.000 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||||
|
2026-07-12 01:56:51.234 +09:00 [INF] Server kimjaehyun-note:18688:50858618 successfully announced in 231.3723 ms
|
||||||
|
2026-07-12 01:56:51.239 +09:00 [INF] Server kimjaehyun-note:18688:50858618 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||||
|
2026-07-12 01:56:51.251 +09:00 [INF] Server kimjaehyun-note:18688:50858618 all the dispatchers started
|
||||||
|
2026-07-12 01:56:52.918 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 01:56:52.923 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||||
|
2026-07-12 01:56:52.975 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:56:53.000 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 01:56:53.008 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 01:56:53.009 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 01:56:53.011 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 01:56:53.012 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 01:56:53.043 +09:00 [INF] Executed page /Account/Login in 38.7733ms
|
||||||
|
2026-07-12 01:56:53.043 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 01:56:53.045 +09:00 [INF] HTTP GET /Account/Login responded 200 in 123.2950 ms
|
||||||
|
2026-07-12 01:56:53.048 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 131.2244ms
|
||||||
|
2026-07-12 10:29:03.142 +09:00 [INF] Registered 10 endpoints in 4,906 milliseconds.
|
||||||
|
2026-07-12 10:29:03.556 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||||
|
2026-07-12 10:29:07.251 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-12 10:29:07.251 +09:00 [ERR] ❌ Database migration failed
|
||||||
|
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||||
|
2026-07-12 10:29:07.276 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
2026-07-12 10:29:07.442 +09:00 [INF] Initializing Hangfire schedules...
|
||||||
|
2026-07-12 10:29:07.565 +09:00 [INF] Hangfire schedules initialized successfully
|
||||||
|
2026-07-12 10:29:07.677 +09:00 [INF] Creating key {49191b44-5683-456a-b1ee-0ddbc2c7fc29} with creation date 2026-07-12 01:29:07Z, activation date 2026-07-12 01:29:07Z, and expiration date 2026-10-10 01:29:07Z.
|
||||||
|
2026-07-12 10:29:07.689 +09:00 [WRN] No XML encryptor configured. Key {49191b44-5683-456a-b1ee-0ddbc2c7fc29} may be persisted to storage in unencrypted form.
|
||||||
|
2026-07-12 10:29:07.697 +09:00 [INF] Writing data to file 'C:\Users\kjh20\AppData\Local\quantengine-keys\key-49191b44-5683-456a-b1ee-0ddbc2c7fc29.xml'.
|
||||||
|
2026-07-12 10:29:07.829 +09:00 [INF] Now listening on: http://localhost:5265
|
||||||
|
2026-07-12 10:29:07.833 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||||
|
2026-07-12 10:29:07.834 +09:00 [INF] Using the following options for Hangfire Server:
|
||||||
|
Worker count: 32
|
||||||
|
Listening queues: 'default'
|
||||||
|
Shutdown timeout: 00:00:15
|
||||||
|
Schedule polling interval: 00:00:15
|
||||||
|
2026-07-12 10:29:07.843 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||||
|
2026-07-12 10:29:07.843 +09:00 [INF] Hosting environment: Development
|
||||||
|
2026-07-12 10:29:07.843 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||||
|
2026-07-12 10:29:07.850 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 successfully announced in 5.0644 ms
|
||||||
|
2026-07-12 10:29:07.852 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||||
|
2026-07-12 10:29:07.863 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 all the dispatchers started
|
||||||
|
2026-07-12 10:29:10.923 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 10:29:10.929 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||||
|
2026-07-12 10:29:10.969 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:29:10.987 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 10:29:10.994 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 10:29:10.995 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 10:29:10.997 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 10:29:10.998 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 10:29:11.026 +09:00 [INF] Executed page /Account/Login in 35.8464ms
|
||||||
|
2026-07-12 10:29:11.027 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:29:11.028 +09:00 [INF] HTTP GET /Account/Login responded 200 in 99.7375 ms
|
||||||
|
2026-07-12 10:29:11.030 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 121.6214ms
|
||||||
|
2026-07-12 10:54:40.138 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-12 10:54:40.144 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 10:54:40.145 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 10:54:40.145 +09:00 [INF] HTTP GET /login responded 302 in 6.0857 ms
|
||||||
|
2026-07-12 10:54:40.146 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 9.206ms
|
||||||
|
2026-07-12 10:54:40.153 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 10:54:40.163 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:40.163 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 10:54:40.165 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:40.165 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 10:54:40.165 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:40.166 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 10:54:40.179 +09:00 [INF] Executed page /Account/Login in 15.2716ms
|
||||||
|
2026-07-12 10:54:40.179 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:40.179 +09:00 [INF] HTTP GET /Account/Login responded 200 in 25.4427 ms
|
||||||
|
2026-07-12 10:54:40.180 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 26.1015ms
|
||||||
|
2026-07-12 10:54:40.185 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-12 10:54:40.186 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 10:54:40.187 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 10:54:40.187 +09:00 [INF] HTTP GET /login responded 302 in 1.4987 ms
|
||||||
|
2026-07-12 10:54:40.187 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 2.2724ms
|
||||||
|
2026-07-12 10:54:40.190 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 10:54:40.191 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:40.191 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 10:54:40.195 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:40.196 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 10:54:40.196 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:40.196 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 10:54:40.203 +09:00 [INF] Executed page /Account/Login in 11.1274ms
|
||||||
|
2026-07-12 10:54:40.203 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:40.203 +09:00 [INF] HTTP GET /Account/Login responded 200 in 12.4260 ms
|
||||||
|
2026-07-12 10:54:40.203 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 13.0096ms
|
||||||
|
2026-07-12 10:54:44.086 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 10:54:44.087 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:44.087 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 10:54:44.087 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:44.087 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 10:54:44.088 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:44.088 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 10:54:44.088 +09:00 [INF] Executed page /Account/Login in 1.1538ms
|
||||||
|
2026-07-12 10:54:44.089 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:44.089 +09:00 [INF] HTTP GET /Account/Login responded 200 in 1.8741 ms
|
||||||
|
2026-07-12 10:54:44.089 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 2.4044ms
|
||||||
|
2026-07-12 10:54:44.647 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 212
|
||||||
|
2026-07-12 10:54:44.649 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:44.649 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 10:54:44.686 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 10:54:48.980 +09:00 [INF] Executed page /Account/Login in 4330.4506ms
|
||||||
|
2026-07-12 10:54:48.981 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 10:54:48.984 +09:00 [ERR] HTTP POST /Account/Login responded 500 in 4336.7156 ms
|
||||||
|
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||||
|
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||||
|
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||||
|
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||||
|
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
Exception data:
|
||||||
|
Severity: FATAL
|
||||||
|
SqlState: 28P01
|
||||||
|
MessageText: password authentication failed for user "quantengine_app"
|
||||||
|
File: auth.c
|
||||||
|
Line: 317
|
||||||
|
Routine: auth_failed
|
||||||
|
2026-07-12 10:54:49.034 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||||
|
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||||
|
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||||
|
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||||
|
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||||
|
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||||
|
Exception data:
|
||||||
|
Severity: FATAL
|
||||||
|
SqlState: 28P01
|
||||||
|
MessageText: password authentication failed for user "quantengine_app"
|
||||||
|
File: auth.c
|
||||||
|
Line: 317
|
||||||
|
Routine: auth_failed
|
||||||
|
2026-07-12 10:54:49.111 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 500 null text/html; charset=utf-8 4464.5985ms
|
||||||
|
2026-07-12 10:54:49.205 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - null null
|
||||||
|
2026-07-12 10:54:49.207 +09:00 [INF] Executing endpoint 'HTTP: GET /api/collection/runs'
|
||||||
|
2026-07-12 10:54:52.982 +09:00 [INF] Executed endpoint 'HTTP: GET /api/collection/runs'
|
||||||
|
2026-07-12 10:54:52.982 +09:00 [ERR] HTTP GET /api/collection/runs responded 500 in 3777.5166 ms
|
||||||
|
2026-07-12 10:54:52.983 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - 500 null application/problem+json; charset=utf-8 3778.0479ms
|
||||||
|
2026-07-12 13:02:07.505 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-12 13:02:07.523 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 13:02:07.523 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 13:02:07.524 +09:00 [INF] HTTP GET /login responded 302 in 16.0250 ms
|
||||||
|
2026-07-12 13:02:07.529 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 21.1915ms
|
||||||
|
2026-07-12 13:02:07.531 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 13:02:07.531 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:07.540 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 13:02:07.546 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:07.548 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 13:02:07.548 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:07.550 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 13:02:07.581 +09:00 [INF] Executed page /Account/Login in 40.445ms
|
||||||
|
2026-07-12 13:02:07.581 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:07.581 +09:00 [INF] HTTP GET /Account/Login responded 200 in 49.9978 ms
|
||||||
|
2026-07-12 13:02:07.581 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 50.4194ms
|
||||||
|
2026-07-12 13:02:07.585 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||||
|
2026-07-12 13:02:07.585 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 13:02:07.585 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||||
|
2026-07-12 13:02:07.585 +09:00 [INF] HTTP GET /login responded 302 in 0.3455 ms
|
||||||
|
2026-07-12 13:02:07.585 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.6015ms
|
||||||
|
2026-07-12 13:02:07.586 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 13:02:07.587 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:07.587 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 13:02:07.587 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:07.587 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 13:02:07.588 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:07.588 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 13:02:07.589 +09:00 [INF] Executed page /Account/Login in 1.3984ms
|
||||||
|
2026-07-12 13:02:07.589 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:07.589 +09:00 [INF] HTTP GET /Account/Login responded 200 in 2.2170 ms
|
||||||
|
2026-07-12 13:02:07.589 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 2.4854ms
|
||||||
|
2026-07-12 13:02:11.141 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||||
|
2026-07-12 13:02:11.142 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:11.142 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 13:02:11.142 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:11.143 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||||
|
2026-07-12 13:02:11.143 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:11.143 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||||
|
2026-07-12 13:02:11.144 +09:00 [INF] Executed page /Account/Login in 1.7154ms
|
||||||
|
2026-07-12 13:02:11.144 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:11.144 +09:00 [INF] HTTP GET /Account/Login responded 200 in 2.8689 ms
|
||||||
|
2026-07-12 13:02:11.144 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 3.2765ms
|
||||||
|
2026-07-12 13:02:11.640 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 212
|
||||||
|
2026-07-12 13:02:11.643 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:11.644 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||||
|
2026-07-12 13:02:11.684 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||||
|
2026-07-12 13:02:15.491 +09:00 [INF] Executed page /Account/Login in 3846.9073ms
|
||||||
|
2026-07-12 13:02:15.493 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||||
|
2026-07-12 13:02:15.495 +09:00 [ERR] HTTP POST /Account/Login responded 500 in 3854.1437 ms
|
||||||
|
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||||
|
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||||
|
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||||
|
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||||
|
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
Exception data:
|
||||||
|
Severity: FATAL
|
||||||
|
SqlState: 28P01
|
||||||
|
MessageText: password authentication failed for user "quantengine_app"
|
||||||
|
File: auth.c
|
||||||
|
Line: 317
|
||||||
|
Routine: auth_failed
|
||||||
|
2026-07-12 13:02:15.530 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||||
|
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||||
|
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||||
|
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||||
|
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||||
|
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||||
|
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||||
|
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||||
|
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||||
|
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||||
|
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||||
|
Exception data:
|
||||||
|
Severity: FATAL
|
||||||
|
SqlState: 28P01
|
||||||
|
MessageText: password authentication failed for user "quantengine_app"
|
||||||
|
File: auth.c
|
||||||
|
Line: 317
|
||||||
|
Routine: auth_failed
|
||||||
|
2026-07-12 13:02:15.576 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 500 null text/html; charset=utf-8 3935.4857ms
|
||||||
|
2026-07-12 13:02:15.664 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - null null
|
||||||
|
2026-07-12 13:02:15.668 +09:00 [INF] Executing endpoint 'HTTP: GET /api/collection/runs'
|
||||||
|
2026-07-12 13:02:19.317 +09:00 [INF] Executed endpoint 'HTTP: GET /api/collection/runs'
|
||||||
|
2026-07-12 13:02:19.318 +09:00 [ERR] HTTP GET /api/collection/runs responded 500 in 3653.6595 ms
|
||||||
|
2026-07-12 13:02:19.318 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - 500 null application/problem+json; charset=utf-8 3654.0261ms
|
||||||
Binary file not shown.
@@ -908,6 +908,8 @@ def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
|
|||||||
errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
|
errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
|
||||||
if not ticker and name != "예수금/D+2현금":
|
if not ticker and name != "예수금/D+2현금":
|
||||||
errors.append(f"account_snapshot row {idx}: ticker required")
|
errors.append(f"account_snapshot row {idx}: ticker required")
|
||||||
|
if ticker and not re.fullmatch(r"(?:\d{6}|[A-Z0-9]{6}|[A-Z]{1,5})", ticker):
|
||||||
|
errors.append(f"account_snapshot row {idx}: ticker must be 6 digits or an uppercase symbol")
|
||||||
if not name:
|
if not name:
|
||||||
errors.append(f"account_snapshot row {idx}: name required")
|
errors.append(f"account_snapshot row {idx}: name required")
|
||||||
if parse_status not in ALLOWED_PARSE_STATUS:
|
if parse_status not in ALLOWED_PARSE_STATUS:
|
||||||
@@ -936,7 +938,7 @@ def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
|
|||||||
errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
|
errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
|
||||||
if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
|
if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
|
||||||
errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
|
errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
|
||||||
if position_type and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
|
if position_type and name != "예수금/D+2현금" and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
|
||||||
errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
|
errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"status": "passed",
|
"status": "failed",
|
||||||
"failedTests": []
|
"failedTests": [
|
||||||
|
"90c6053e24d905f92d61-fe6c7d0382f9666a596d"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
# Instructions
|
||||||
|
|
||||||
|
- Following Playwright test failed.
|
||||||
|
- Explain why, be concise, respect Playwright best practices.
|
||||||
|
- Provide a snippet of code with the fix, if possible.
|
||||||
|
|
||||||
|
# Test info
|
||||||
|
|
||||||
|
- Name: evidence\qe-m1-02-collection-run.spec.ts >> QE-M1-02: Collection Run List & Detail Verification >> QE-M1-02: Collection run renders in list with API-derived expected values
|
||||||
|
- Location: tests\e2e\evidence\qe-m1-02-collection-run.spec.ts:24:3
|
||||||
|
|
||||||
|
# Error details
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: expect(received).toBeTruthy()
|
||||||
|
|
||||||
|
Received: false
|
||||||
|
```
|
||||||
|
|
||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- heading "An unhandled exception occurred while processing the request." [level=1] [ref=e2]
|
||||||
|
- generic [ref=e3]: "PostgresException: 28P01: password authentication failed for user \"quantengine_app\""
|
||||||
|
- paragraph [ref=e4]: Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)
|
||||||
|
- list [ref=e5]:
|
||||||
|
- listitem [ref=e6] [cursor=pointer]: Stack
|
||||||
|
- listitem [ref=e7] [cursor=pointer]: Query
|
||||||
|
- listitem [ref=e8] [cursor=pointer]: Cookies
|
||||||
|
- listitem [ref=e9] [cursor=pointer]: Headers
|
||||||
|
- listitem [ref=e10] [cursor=pointer]: Routing
|
||||||
|
- list [ref=e12]:
|
||||||
|
- listitem [ref=e13]:
|
||||||
|
- 'heading "PostgresException: 28P01: password authentication failed for user \"quantengine_app\"" [level=2] [ref=e14]'
|
||||||
|
- list [ref=e15]:
|
||||||
|
- listitem [ref=e16]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)" [level=3] [ref=e17]
|
||||||
|
- listitem [ref=e18]:
|
||||||
|
- heading "System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder<TResult>+StateMachineBox<TStateMachine>.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(short token)" [level=3] [ref=e19]
|
||||||
|
- listitem [ref=e20]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List<string> mechanisms, string username, bool async, CancellationToken cancellationToken)" [level=3] [ref=e21]
|
||||||
|
- listitem [ref=e22]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.Authenticate(string username, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e23]
|
||||||
|
- listitem [ref=e24]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e25]
|
||||||
|
- listitem [ref=e26]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e27]
|
||||||
|
- listitem [ref=e28]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e29]
|
||||||
|
- listitem [ref=e30]:
|
||||||
|
- heading "Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e31]
|
||||||
|
- listitem [ref=e32]:
|
||||||
|
- heading "Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e33]
|
||||||
|
- listitem [ref=e34]:
|
||||||
|
- heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e35]
|
||||||
|
- listitem [ref=e36]:
|
||||||
|
- heading "Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e37]
|
||||||
|
- listitem [ref=e38]:
|
||||||
|
- heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e39]
|
||||||
|
- listitem [ref=e40]:
|
||||||
|
- heading "Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(bool async, CancellationToken cancellationToken)" [level=3] [ref=e41]
|
||||||
|
- listitem [ref=e42]:
|
||||||
|
- heading "Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in SqlMapper.Async.cs" [level=3] [ref=e43]:
|
||||||
|
- text: Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in
|
||||||
|
- code [ref=e44]: SqlMapper.Async.cs
|
||||||
|
- listitem [ref=e45]:
|
||||||
|
- heading "QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in WorkspaceRepository.cs" [level=3] [ref=e46]:
|
||||||
|
- text: QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in
|
||||||
|
- code [ref=e47]: WorkspaceRepository.cs
|
||||||
|
- button "+" [ref=e48] [cursor=pointer]
|
||||||
|
- list [ref=e50]:
|
||||||
|
- listitem [ref=e51]: return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
|
||||||
|
- listitem [ref=e52]:
|
||||||
|
- heading "QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in AuthService.cs" [level=3] [ref=e53]:
|
||||||
|
- text: QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in
|
||||||
|
- code [ref=e54]: AuthService.cs
|
||||||
|
- button "+" [ref=e55] [cursor=pointer]
|
||||||
|
- list [ref=e57]:
|
||||||
|
- listitem [ref=e58]: var account = await _workspaceRepository.GetAccountByUsernameAsync(username.Trim());
|
||||||
|
- listitem [ref=e59]:
|
||||||
|
- heading "QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in Login.cshtml.cs" [level=3] [ref=e60]:
|
||||||
|
- text: QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in
|
||||||
|
- code [ref=e61]: Login.cshtml.cs
|
||||||
|
- button "+" [ref=e62] [cursor=pointer]
|
||||||
|
- list [ref=e64]:
|
||||||
|
- listitem [ref=e65]: var account = await _authService.AuthenticateAsync(username, password, ipAddress);
|
||||||
|
- listitem [ref=e66]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Convert<T>(object taskAsObject)" [level=3] [ref=e67]
|
||||||
|
- listitem [ref=e68]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Execute(object receiver, object[] arguments)" [level=3] [ref=e69]
|
||||||
|
- listitem [ref=e70]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()" [level=3] [ref=e71]
|
||||||
|
- listitem [ref=e72]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()" [level=3] [ref=e73]
|
||||||
|
- listitem [ref=e74]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)" [level=3] [ref=e75]
|
||||||
|
- listitem [ref=e76]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e77]
|
||||||
|
- listitem [ref=e78]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()" [level=3] [ref=e79]
|
||||||
|
- listitem [ref=e80]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e81]
|
||||||
|
- listitem [ref=e82]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)" [level=3] [ref=e83]
|
||||||
|
- listitem [ref=e84]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e85]
|
||||||
|
- listitem [ref=e86]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e87]
|
||||||
|
- listitem [ref=e88]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e89]
|
||||||
|
- listitem [ref=e90]:
|
||||||
|
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e91]
|
||||||
|
- listitem [ref=e92]:
|
||||||
|
- heading "Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)" [level=3] [ref=e93]
|
||||||
|
- listitem [ref=e94]:
|
||||||
|
- heading "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e95]
|
||||||
|
- listitem [ref=e96]:
|
||||||
|
- heading "Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e97]
|
||||||
|
- listitem [ref=e98]:
|
||||||
|
- heading "Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)" [level=3] [ref=e99]
|
||||||
|
- listitem [ref=e100]:
|
||||||
|
- heading "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)" [level=3] [ref=e101]
|
||||||
|
- listitem [ref=e102]:
|
||||||
|
- button "Show raw exception details" [ref=e104] [cursor=pointer]
|
||||||
|
```
|
||||||
|
|
||||||
|
# Test source
|
||||||
|
|
||||||
|
```ts
|
||||||
|
1 | import { test, expect } from '@playwright/test';
|
||||||
|
2 | import * as fs from 'fs';
|
||||||
|
3 | import * as path from 'path';
|
||||||
|
4 |
|
||||||
|
5 | test.describe('QE-M1-02: Collection Run List & Detail Verification', () => {
|
||||||
|
6 | // Login before each test
|
||||||
|
7 | test.beforeEach(async ({ page }) => {
|
||||||
|
8 | await page.goto('/Account/Login');
|
||||||
|
9 | await page.waitForLoadState('domcontentloaded');
|
||||||
|
10 |
|
||||||
|
11 | // Fill login form with credentials (admin/admin)
|
||||||
|
12 | const usernameInput = page.locator('#username');
|
||||||
|
13 | const passwordInput = page.locator('#password');
|
||||||
|
14 | const loginButton = page.locator('#loginBtn');
|
||||||
|
15 |
|
||||||
|
16 | await usernameInput.fill('admin');
|
||||||
|
17 | await passwordInput.fill('admin');
|
||||||
|
18 | await loginButton.click();
|
||||||
|
19 |
|
||||||
|
20 | // Wait for login to complete
|
||||||
|
21 | await page.waitForLoadState('domcontentloaded');
|
||||||
|
22 | });
|
||||||
|
23 |
|
||||||
|
24 | test('QE-M1-02: Collection run renders in list with API-derived expected values', async ({ page }) => {
|
||||||
|
25 | // Step 1: Fetch expected values from API (source of truth)
|
||||||
|
26 | const apiResponse = await page.request.get('/api/collection/runs?limit=20');
|
||||||
|
> 27 | expect(apiResponse.ok()).toBeTruthy();
|
||||||
|
| ^ Error: expect(received).toBeTruthy()
|
||||||
|
28 |
|
||||||
|
29 | const responseJson = await apiResponse.json();
|
||||||
|
30 | const runs = (responseJson as any).runs || [];
|
||||||
|
31 |
|
||||||
|
32 | // Fail if no collection runs exist in database
|
||||||
|
33 | if (runs.length === 0) {
|
||||||
|
34 | throw new Error(
|
||||||
|
35 | 'No collection runs in DB — run the daily-collection job first. ' +
|
||||||
|
36 | 'Expected at least 1 run from kis_collection_runs table.'
|
||||||
|
37 | );
|
||||||
|
38 | }
|
||||||
|
39 |
|
||||||
|
40 | // Extract expected values from most recent run (first in list)
|
||||||
|
41 | const expectedRun = runs[0];
|
||||||
|
42 | const expectedRunId = expectedRun.runId;
|
||||||
|
43 | const expectedTotalSnapshots = expectedRun.totalSnapshots ?? 0;
|
||||||
|
44 | const expectedStatus = expectedRun.status; // e.g., "completed", "running", "failed"
|
||||||
|
45 |
|
||||||
|
46 | // Map status to Korean text (same logic as Index.cshtml — unknown statuses
|
||||||
|
47 | // like COMPLETED_WITH_ERRORS render the raw status string in a secondary badge)
|
||||||
|
48 | let expectedStatusText = String(expectedStatus ?? '');
|
||||||
|
49 | if (expectedStatus?.toLowerCase() === 'completed') {
|
||||||
|
50 | expectedStatusText = '완료';
|
||||||
|
51 | } else if (expectedStatus?.toLowerCase() === 'running') {
|
||||||
|
52 | expectedStatusText = '진행 중';
|
||||||
|
53 | } else if (expectedStatus?.toLowerCase() === 'failed') {
|
||||||
|
54 | expectedStatusText = '실패';
|
||||||
|
55 | }
|
||||||
|
56 |
|
||||||
|
57 | console.log(
|
||||||
|
58 | `\n=== QE-M1-02 Test Started ===\n` +
|
||||||
|
59 | `Expected RunId: ${expectedRunId}\n` +
|
||||||
|
60 | `Expected TotalSnapshots: ${expectedTotalSnapshots}\n` +
|
||||||
|
61 | `Expected Status: ${expectedStatus} (rendered as: ${expectedStatusText})\n`
|
||||||
|
62 | );
|
||||||
|
63 |
|
||||||
|
64 | // Step 2: Navigate to Collection admin page
|
||||||
|
65 | await page.goto('/Admin/Collection');
|
||||||
|
66 | await page.waitForLoadState('domcontentloaded');
|
||||||
|
67 |
|
||||||
|
68 | // Step 3: Verify page title contains "데이터 수집" (collection)
|
||||||
|
69 | const pageTitle = await page.title();
|
||||||
|
70 | expect(pageTitle).toContain('데이터 수집');
|
||||||
|
71 |
|
||||||
|
72 | // Step 4: Assert that a row containing the expected runId is visible
|
||||||
|
73 | const runIdCell = page.locator(`td:has-text("${expectedRunId}")`);
|
||||||
|
74 | await expect(runIdCell).toBeVisible();
|
||||||
|
75 | console.log(`✓ RunId row found and visible: ${expectedRunId}`);
|
||||||
|
76 |
|
||||||
|
77 | // Step 5: Find the row containing this runId and verify the snapshot count
|
||||||
|
78 | const tableRow = runIdCell.locator('xpath=ancestor::tr');
|
||||||
|
79 |
|
||||||
|
80 | // Within the row, find all td elements and map to columns
|
||||||
|
81 | // Columns: 실행 ID (0), 시작 시간 (1), 종료 시간 (2), 상태 (3), 스냅샷 수 (4), 오류 수 (5)
|
||||||
|
82 | const cells = tableRow.locator('td');
|
||||||
|
83 | const cellCount = await cells.count();
|
||||||
|
84 | expect(cellCount).toBeGreaterThanOrEqual(5); // At least 5 columns
|
||||||
|
85 |
|
||||||
|
86 | // Cell 4 (index 4) is "스냅샷 수" (total snapshots)
|
||||||
|
87 | const snapshotCell = cells.nth(4);
|
||||||
|
88 | const snapshotText = await snapshotCell.textContent();
|
||||||
|
89 | expect(snapshotText?.trim()).toBe(String(expectedTotalSnapshots));
|
||||||
|
90 | console.log(`✓ Snapshot count matches: ${snapshotText?.trim()} == ${expectedTotalSnapshots}`);
|
||||||
|
91 |
|
||||||
|
92 | // Cell 3 (index 3) is "상태" (status badge)
|
||||||
|
93 | const statusCell = cells.nth(3);
|
||||||
|
94 | const statusBadge = statusCell.locator('span.badge');
|
||||||
|
95 | const statusBadgeText = await statusBadge.textContent();
|
||||||
|
96 | expect(statusBadgeText?.trim()).toBe(expectedStatusText);
|
||||||
|
97 | console.log(`✓ Status badge matches: ${statusBadgeText?.trim()} == ${expectedStatusText}`);
|
||||||
|
98 |
|
||||||
|
99 | // Step 6: Create screenshot directory and take screenshot of collection list
|
||||||
|
100 | const screenshotDir = path.join(process.cwd(), 'Temp', 'evidence', 'QE-M1-02', 'screenshots');
|
||||||
|
101 | fs.mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
102 |
|
||||||
|
103 | await page.screenshot({
|
||||||
|
104 | path: path.join(screenshotDir, '01-collection-page.png'),
|
||||||
|
105 | fullPage: true,
|
||||||
|
106 | });
|
||||||
|
107 | console.log(`✓ Screenshot saved: 01-collection-page.png`);
|
||||||
|
108 |
|
||||||
|
109 | // Step 7: Navigate to the run detail page
|
||||||
|
110 | // The detail page route is /Admin/Collection/{runId}
|
||||||
|
111 | await page.goto(`/Admin/Collection/${expectedRunId}`);
|
||||||
|
112 | await page.waitForLoadState('domcontentloaded');
|
||||||
|
113 |
|
||||||
|
114 | // Step 8: Verify detail page title contains the runId
|
||||||
|
115 | const detailPageTitle = await page.title();
|
||||||
|
116 | expect(detailPageTitle).toContain('수집 실행 상세');
|
||||||
|
117 |
|
||||||
|
118 | // Step 9: Verify that the RunId is displayed on the detail page
|
||||||
|
119 | // The page title shows: "수집 실행 상세 - {runId}"
|
||||||
|
120 | const pageHeading = page.locator('h2.page-title');
|
||||||
|
121 | const headingText = await pageHeading.textContent();
|
||||||
|
122 | expect(headingText).toContain(expectedRunId);
|
||||||
|
123 | console.log(`✓ Detail page title contains RunId: ${headingText}`);
|
||||||
|
124 |
|
||||||
|
125 | // Step 10: Verify snapshots count is displayed on detail page
|
||||||
|
126 | // The snapshot count appears in a card with "스냅샷 수" as the title
|
||||||
|
127 | const snapshotCountCard = page.locator('h4.card-title:has-text("스냅샷 수")');
|
||||||
|
```
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user